diff --git a/.agents/skills/frontend-testing/assets/component-test.template.tsx b/.agents/skills/frontend-testing/assets/component-test.template.tsx index 6b7803bd4b..ff38f88d23 100644 --- a/.agents/skills/frontend-testing/assets/component-test.template.tsx +++ b/.agents/skills/frontend-testing/assets/component-test.template.tsx @@ -41,7 +41,7 @@ import userEvent from '@testing-library/user-event' // Router (if component uses useRouter, usePathname, useSearchParams) // WHY: Isolates tests from Next.js routing, enables testing navigation behavior // const mockPush = vi.fn() -// vi.mock('next/navigation', () => ({ +// vi.mock('@/next/navigation', () => ({ // useRouter: () => ({ push: mockPush }), // usePathname: () => '/test-path', // })) diff --git a/.github/actions/setup-web/action.yml b/.github/actions/setup-web/action.yml index 54702c914a..6f3b3c08b4 100644 --- a/.github/actions/setup-web/action.yml +++ b/.github/actions/setup-web/action.yml @@ -4,10 +4,10 @@ runs: using: composite steps: - name: Setup Vite+ - uses: voidzero-dev/setup-vp@b5d848f5a62488f3d3d920f8aa6ac318a60c5f07 # v1 + uses: voidzero-dev/setup-vp@4a524139920f87f9f7080d3b8545acac019e1852 # v1.0.0 with: - node-version-file: "./web/.nvmrc" + node-version-file: web/.nvmrc cache: true + cache-dependency-path: web/pnpm-lock.yaml run-install: | - - cwd: ./web - args: ['--frozen-lockfile'] + cwd: ./web diff --git a/.github/workflows/anti-slop.yml b/.github/workflows/anti-slop.yml index c0d1818691..b0f0a36bc9 100644 --- a/.github/workflows/anti-slop.yml +++ b/.github/workflows/anti-slop.yml @@ -12,7 +12,7 @@ jobs: anti-slop: runs-on: ubuntu-latest steps: - - uses: peakoss/anti-slop@v0 + - uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1 with: github-token: ${{ secrets.GITHUB_TOKEN }} close-pr: false diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 12d7ff33c7..6b87946221 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -2,6 +2,12 @@ name: Run Pytest on: workflow_call: + secrets: + CODECOV_TOKEN: + required: false + +permissions: + contents: read concurrency: group: api-tests-${{ github.head_ref || github.run_id }} @@ -11,6 +17,8 @@ jobs: test: name: API Tests runs-on: ubuntu-latest + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: run: shell: bash @@ -24,10 +32,11 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + fetch-depth: 0 persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -79,21 +88,12 @@ jobs: api/tests/test_containers_integration_tests \ api/tests/unit_tests - - name: Coverage Summary - run: | - set -x - # Extract coverage percentage and create a summary - TOTAL_COVERAGE=$(python -c 'import json; print(json.load(open("coverage.json"))["totals"]["percent_covered_display"])') - - # Create a detailed coverage summary - echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY - echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY - { - echo "" - echo "
File-level coverage (click to expand)" - echo "" - echo '```' - uv run --project api coverage report -m - echo '```' - echo "
" - } >> $GITHUB_STEP_SUMMARY + - name: Report coverage + if: ${{ env.CODECOV_TOKEN != '' && matrix.python-version == '3.12' }} + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + with: + files: ./coverage.xml + disable_search: true + flags: api + env: + CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 73ca94f98f..8947ae4030 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -39,7 +39,7 @@ jobs: with: python-version: "3.11" - - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Generate Docker Compose if: steps.docker-compose-changes.outputs.any_changed == 'true' diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index c567a4bfe0..ffb9734e48 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: "3.12" @@ -69,7 +69,7 @@ jobs: persist-credentials: false - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: "3.12" diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index a19cb50abc..69023c24cc 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -56,16 +56,14 @@ jobs: needs: check-changes if: needs.check-changes.outputs.api-changed == 'true' uses: ./.github/workflows/api-tests.yml + secrets: inherit web-tests: name: Web Tests needs: check-changes if: needs.check-changes.outputs.web-changed == 'true' uses: ./.github/workflows/web-tests.yml - with: - base_sha: ${{ github.event.before || github.event.pull_request.base.sha }} - diff_range_mode: ${{ github.event.before && 'exact' || 'merge-base' }} - head_sha: ${{ github.event.after || github.event.pull_request.head.sha || github.sha }} + secrets: inherit style-check: name: Style Check diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index f50df229d5..a00f469bbe 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Python & UV - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 5e037d2541..657a481f74 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -33,7 +33,7 @@ jobs: - name: Setup UV and Python if: steps.changed-files.outputs.any_changed == 'true' - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false python-version: "3.12" diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 9af6649328..849f965c36 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -120,7 +120,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.detect_changes.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72 + uses: anthropics/claude-code-action@df37d2f0760a4b5683a6e617c9325bc1a36443f6 # v1.0.75 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index 0b771c1af7..f45f2137d6 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -31,7 +31,7 @@ jobs: remove_tool_cache: true - name: Setup UV and Python - uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index be2595a599..d40cd4bfeb 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -2,16 +2,9 @@ name: Web Tests on: workflow_call: - inputs: - base_sha: + secrets: + CODECOV_TOKEN: required: false - type: string - diff_range_mode: - required: false - type: string - head_sha: - required: false - type: string permissions: contents: read @@ -63,7 +56,7 @@ jobs: needs: [test] runs-on: ubuntu-latest env: - VITEST_COVERAGE_SCOPE: app-components + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: run: shell: bash @@ -87,52 +80,16 @@ jobs: merge-multiple: true - name: Merge reports - run: vp test --merge-reports --reporter=json --reporter=agent --coverage + run: vp test --merge-reports --coverage --silent=passed-only - - name: Report app/components baseline coverage - run: node ./scripts/report-components-coverage-baseline.mjs - - - name: Report app/components test touch - env: - BASE_SHA: ${{ inputs.base_sha }} - DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} - HEAD_SHA: ${{ inputs.head_sha }} - run: node ./scripts/report-components-test-touch.mjs - - - name: Check app/components pure diff coverage - env: - BASE_SHA: ${{ inputs.base_sha }} - DIFF_RANGE_MODE: ${{ inputs.diff_range_mode }} - HEAD_SHA: ${{ inputs.head_sha }} - run: node ./scripts/check-components-diff-coverage.mjs - - - name: Check Coverage Summary - if: always() - id: coverage-summary - run: | - set -eo pipefail - - COVERAGE_FILE="coverage/coverage-final.json" - COVERAGE_SUMMARY_FILE="coverage/coverage-summary.json" - - if [ -f "$COVERAGE_FILE" ] || [ -f "$COVERAGE_SUMMARY_FILE" ]; then - echo "has_coverage=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "has_coverage=false" >> "$GITHUB_OUTPUT" - echo "### 🚨 app/components Diff Coverage" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Coverage artifacts not found. Ensure Vitest merge reports ran with coverage enabled." >> "$GITHUB_STEP_SUMMARY" - - - name: Upload Coverage Artifact - if: steps.coverage-summary.outputs.has_coverage == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + - name: Report coverage + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 with: - name: web-coverage-report - path: web/coverage - retention-days: 30 - if-no-files-found: error + directory: web/coverage + flags: web + env: + CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} web-build: name: Web Build diff --git a/api/commands/plugin.py b/api/commands/plugin.py index 2dfbd73b3a..c34391025a 100644 --- a/api/commands/plugin.py +++ b/api/commands/plugin.py @@ -1,9 +1,11 @@ import json import logging -from typing import Any +from typing import Any, cast import click from pydantic import TypeAdapter +from sqlalchemy import delete, select +from sqlalchemy.engine import CursorResult from configs import dify_config from core.helper import encrypter @@ -48,14 +50,15 @@ def setup_system_tool_oauth_client(provider, client_params): click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) return - deleted_count = ( - db.session.query(ToolOAuthSystemClient) - .filter_by( - provider=provider_name, - plugin_id=plugin_id, - ) - .delete() - ) + deleted_count = cast( + CursorResult, + db.session.execute( + delete(ToolOAuthSystemClient).where( + ToolOAuthSystemClient.provider == provider_name, + ToolOAuthSystemClient.plugin_id == plugin_id, + ) + ), + ).rowcount if deleted_count > 0: click.echo(click.style(f"Deleted {deleted_count} existing oauth client params.", fg="yellow")) @@ -97,14 +100,15 @@ def setup_system_trigger_oauth_client(provider, client_params): click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) return - deleted_count = ( - db.session.query(TriggerOAuthSystemClient) - .filter_by( - provider=provider_name, - plugin_id=plugin_id, - ) - .delete() - ) + deleted_count = cast( + CursorResult, + db.session.execute( + delete(TriggerOAuthSystemClient).where( + TriggerOAuthSystemClient.provider == provider_name, + TriggerOAuthSystemClient.plugin_id == plugin_id, + ) + ), + ).rowcount if deleted_count > 0: click.echo(click.style(f"Deleted {deleted_count} existing oauth client params.", fg="yellow")) @@ -139,14 +143,15 @@ def setup_datasource_oauth_client(provider, client_params): return click.echo(click.style(f"Ready to delete existing oauth client params: {provider_name}", fg="yellow")) - deleted_count = ( - db.session.query(DatasourceOauthParamConfig) - .filter_by( - provider=provider_name, - plugin_id=plugin_id, - ) - .delete() - ) + deleted_count = cast( + CursorResult, + db.session.execute( + delete(DatasourceOauthParamConfig).where( + DatasourceOauthParamConfig.provider == provider_name, + DatasourceOauthParamConfig.plugin_id == plugin_id, + ) + ), + ).rowcount if deleted_count > 0: click.echo(click.style(f"Deleted {deleted_count} existing oauth client params.", fg="yellow")) @@ -192,7 +197,9 @@ def transform_datasource_credentials(environment: str): # deal notion credentials deal_notion_count = 0 - notion_credentials = db.session.query(DataSourceOauthBinding).filter_by(provider="notion").all() + notion_credentials = db.session.scalars( + select(DataSourceOauthBinding).where(DataSourceOauthBinding.provider == "notion") + ).all() if notion_credentials: notion_credentials_tenant_mapping: dict[str, list[DataSourceOauthBinding]] = {} for notion_credential in notion_credentials: @@ -201,7 +208,7 @@ def transform_datasource_credentials(environment: str): notion_credentials_tenant_mapping[tenant_id] = [] notion_credentials_tenant_mapping[tenant_id].append(notion_credential) for tenant_id, notion_tenant_credentials in notion_credentials_tenant_mapping.items(): - tenant = db.session.query(Tenant).filter_by(id=tenant_id).first() + tenant = db.session.scalar(select(Tenant).where(Tenant.id == tenant_id)) if not tenant: continue try: @@ -250,7 +257,9 @@ def transform_datasource_credentials(environment: str): db.session.commit() # deal firecrawl credentials deal_firecrawl_count = 0 - firecrawl_credentials = db.session.query(DataSourceApiKeyAuthBinding).filter_by(provider="firecrawl").all() + firecrawl_credentials = db.session.scalars( + select(DataSourceApiKeyAuthBinding).where(DataSourceApiKeyAuthBinding.provider == "firecrawl") + ).all() if firecrawl_credentials: firecrawl_credentials_tenant_mapping: dict[str, list[DataSourceApiKeyAuthBinding]] = {} for firecrawl_credential in firecrawl_credentials: @@ -259,7 +268,7 @@ def transform_datasource_credentials(environment: str): firecrawl_credentials_tenant_mapping[tenant_id] = [] firecrawl_credentials_tenant_mapping[tenant_id].append(firecrawl_credential) for tenant_id, firecrawl_tenant_credentials in firecrawl_credentials_tenant_mapping.items(): - tenant = db.session.query(Tenant).filter_by(id=tenant_id).first() + tenant = db.session.scalar(select(Tenant).where(Tenant.id == tenant_id)) if not tenant: continue try: @@ -312,7 +321,9 @@ def transform_datasource_credentials(environment: str): db.session.commit() # deal jina credentials deal_jina_count = 0 - jina_credentials = db.session.query(DataSourceApiKeyAuthBinding).filter_by(provider="jinareader").all() + jina_credentials = db.session.scalars( + select(DataSourceApiKeyAuthBinding).where(DataSourceApiKeyAuthBinding.provider == "jinareader") + ).all() if jina_credentials: jina_credentials_tenant_mapping: dict[str, list[DataSourceApiKeyAuthBinding]] = {} for jina_credential in jina_credentials: @@ -321,7 +332,7 @@ def transform_datasource_credentials(environment: str): jina_credentials_tenant_mapping[tenant_id] = [] jina_credentials_tenant_mapping[tenant_id].append(jina_credential) for tenant_id, jina_tenant_credentials in jina_credentials_tenant_mapping.items(): - tenant = db.session.query(Tenant).filter_by(id=tenant_id).first() + tenant = db.session.scalar(select(Tenant).where(Tenant.id == tenant_id)) if not tenant: continue try: diff --git a/api/commands/storage.py b/api/commands/storage.py index fa890a855a..f23b17680a 100644 --- a/api/commands/storage.py +++ b/api/commands/storage.py @@ -1,7 +1,10 @@ import json +from typing import cast import click import sqlalchemy as sa +from sqlalchemy import update +from sqlalchemy.engine import CursorResult from configs import dify_config from extensions.ext_database import db @@ -740,14 +743,17 @@ def migrate_oss( else: try: source_storage_type = StorageType.LOCAL if is_source_local else StorageType.OPENDAL - updated = ( - db.session.query(UploadFile) - .where( - UploadFile.storage_type == source_storage_type, - UploadFile.key.in_(copied_upload_file_keys), - ) - .update({UploadFile.storage_type: dify_config.STORAGE_TYPE}, synchronize_session=False) - ) + updated = cast( + CursorResult, + db.session.execute( + update(UploadFile) + .where( + UploadFile.storage_type == source_storage_type, + UploadFile.key.in_(copied_upload_file_keys), + ) + .values(storage_type=dify_config.STORAGE_TYPE) + ), + ).rowcount db.session.commit() click.echo(click.style(f"Updated storage_type for {updated} upload_files records.", fg="green")) except Exception as e: diff --git a/api/commands/system.py b/api/commands/system.py index 604f0e34d0..39b2e991ed 100644 --- a/api/commands/system.py +++ b/api/commands/system.py @@ -2,6 +2,7 @@ import logging import click import sqlalchemy as sa +from sqlalchemy import delete, select, update from sqlalchemy.orm import sessionmaker from configs import dify_config @@ -41,7 +42,7 @@ def reset_encrypt_key_pair(): click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red")) return with sessionmaker(db.engine, expire_on_commit=False).begin() as session: - tenants = session.query(Tenant).all() + tenants = session.scalars(select(Tenant)).all() for tenant in tenants: if not tenant: click.echo(click.style("No workspaces found. Run /install first.", fg="red")) @@ -49,8 +50,8 @@ def reset_encrypt_key_pair(): tenant.encrypt_public_key = generate_key_pair(tenant.id) - session.query(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id).delete() - session.query(ProviderModel).where(ProviderModel.tenant_id == tenant.id).delete() + session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id)) + session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id)) click.echo( click.style( @@ -93,7 +94,7 @@ def convert_to_agent_apps(): app_id = str(i.id) if app_id not in proceeded_app_ids: proceeded_app_ids.append(app_id) - app = db.session.query(App).where(App.id == app_id).first() + app = db.session.scalar(select(App).where(App.id == app_id)) if app is not None: apps.append(app) @@ -108,8 +109,8 @@ def convert_to_agent_apps(): db.session.commit() # update conversation mode to agent - db.session.query(Conversation).where(Conversation.app_id == app.id).update( - {Conversation.mode: AppMode.AGENT_CHAT} + db.session.execute( + update(Conversation).where(Conversation.app_id == app.id).values(mode=AppMode.AGENT_CHAT) ) db.session.commit() @@ -177,7 +178,7 @@ where sites.id is null limit 1000""" continue try: - app = db.session.query(App).where(App.id == app_id).first() + app = db.session.scalar(select(App).where(App.id == app_id)) if not app: logger.info("App %s not found", app_id) continue diff --git a/api/commands/vector.py b/api/commands/vector.py index 52ce26c26d..4cf11c9ad1 100644 --- a/api/commands/vector.py +++ b/api/commands/vector.py @@ -41,14 +41,13 @@ def migrate_annotation_vector_database(): # get apps info per_page = 50 with sessionmaker(db.engine, expire_on_commit=False).begin() as session: - apps = ( - session.query(App) + apps = session.scalars( + select(App) .where(App.status == "normal") .order_by(App.created_at.desc()) .limit(per_page) .offset((page - 1) * per_page) - .all() - ) + ).all() if not apps: break except SQLAlchemyError: @@ -63,8 +62,8 @@ def migrate_annotation_vector_database(): try: click.echo(f"Creating app annotation index: {app.id}") with sessionmaker(db.engine, expire_on_commit=False).begin() as session: - app_annotation_setting = ( - session.query(AppAnnotationSetting).where(AppAnnotationSetting.app_id == app.id).first() + app_annotation_setting = session.scalar( + select(AppAnnotationSetting).where(AppAnnotationSetting.app_id == app.id).limit(1) ) if not app_annotation_setting: @@ -72,10 +71,10 @@ def migrate_annotation_vector_database(): click.echo(f"App annotation setting disabled: {app.id}") continue # get dataset_collection_binding info - dataset_collection_binding = ( - session.query(DatasetCollectionBinding) - .where(DatasetCollectionBinding.id == app_annotation_setting.collection_binding_id) - .first() + dataset_collection_binding = session.scalar( + select(DatasetCollectionBinding).where( + DatasetCollectionBinding.id == app_annotation_setting.collection_binding_id + ) ) if not dataset_collection_binding: click.echo(f"App annotation collection binding not found: {app.id}") @@ -205,11 +204,11 @@ def migrate_knowledge_vector_database(): collection_name = Dataset.gen_collection_name_by_id(dataset_id) elif vector_type == VectorType.QDRANT: if dataset.collection_binding_id: - dataset_collection_binding = ( - db.session.query(DatasetCollectionBinding) - .where(DatasetCollectionBinding.id == dataset.collection_binding_id) - .one_or_none() - ) + dataset_collection_binding = db.session.execute( + select(DatasetCollectionBinding).where( + DatasetCollectionBinding.id == dataset.collection_binding_id + ) + ).scalar_one_or_none() if dataset_collection_binding: collection_name = dataset_collection_binding.collection_name else: @@ -334,7 +333,7 @@ def add_qdrant_index(field: str): create_count = 0 try: - bindings = db.session.query(DatasetCollectionBinding).all() + bindings = db.session.scalars(select(DatasetCollectionBinding)).all() if not bindings: click.echo(click.style("No dataset collection bindings found.", fg="red")) return @@ -421,10 +420,10 @@ def old_metadata_migration(): if field.value == key: break else: - dataset_metadata = ( - db.session.query(DatasetMetadata) + dataset_metadata = db.session.scalar( + select(DatasetMetadata) .where(DatasetMetadata.dataset_id == document.dataset_id, DatasetMetadata.name == key) - .first() + .limit(1) ) if not dataset_metadata: dataset_metadata = DatasetMetadata( @@ -436,7 +435,7 @@ def old_metadata_migration(): ) db.session.add(dataset_metadata) db.session.flush() - dataset_metadata_binding = DatasetMetadataBinding( + dataset_metadata_binding: DatasetMetadataBinding | None = DatasetMetadataBinding( tenant_id=document.tenant_id, dataset_id=document.dataset_id, metadata_id=dataset_metadata.id, @@ -445,14 +444,14 @@ def old_metadata_migration(): ) db.session.add(dataset_metadata_binding) else: - dataset_metadata_binding = ( - db.session.query(DatasetMetadataBinding) # type: ignore + dataset_metadata_binding = db.session.scalar( + select(DatasetMetadataBinding) .where( DatasetMetadataBinding.dataset_id == document.dataset_id, DatasetMetadataBinding.document_id == document.id, DatasetMetadataBinding.metadata_id == dataset_metadata.id, ) - .first() + .limit(1) ) if not dataset_metadata_binding: dataset_metadata_binding = DatasetMetadataBinding( diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py index 2025048e09..4b20418b53 100644 --- a/api/controllers/console/app/mcp_server.py +++ b/api/controllers/console/app/mcp_server.py @@ -103,13 +103,13 @@ class AppMCPServerController(Resource): raise NotFound() description = payload.description - if description is None: - pass - elif not description: + if description is None or not description: server.description = app_model.description or "" else: server.description = description + server.name = app_model.name + server.parameters = json.dumps(payload.parameters, ensure_ascii=False) if payload.status: try: diff --git a/api/controllers/console/datasets/hit_testing_base.py b/api/controllers/console/datasets/hit_testing_base.py index 99ff49d79d..cd568cf835 100644 --- a/api/controllers/console/datasets/hit_testing_base.py +++ b/api/controllers/console/datasets/hit_testing_base.py @@ -24,6 +24,7 @@ from fields.hit_testing_fields import hit_testing_record_fields from libs.login import current_user from models.account import Account from services.dataset_service import DatasetService +from services.entities.knowledge_entities.knowledge_entities import RetrievalModel from services.hit_testing_service import HitTestingService logger = logging.getLogger(__name__) @@ -31,7 +32,7 @@ logger = logging.getLogger(__name__) class HitTestingPayload(BaseModel): query: str = Field(max_length=250) - retrieval_model: dict[str, Any] | None = None + retrieval_model: RetrievalModel | None = None external_retrieval_model: dict[str, Any] | None = None attachment_ids: list[str] | None = None diff --git a/api/controllers/console/explore/banner.py b/api/controllers/console/explore/banner.py index da306fbc9d..5dfef6bf6a 100644 --- a/api/controllers/console/explore/banner.py +++ b/api/controllers/console/explore/banner.py @@ -4,6 +4,7 @@ from flask_restx import Resource from controllers.console import api from controllers.console.explore.wraps import explore_banner_enabled from extensions.ext_database import db +from models.enums import BannerStatus from models.model import ExporleBanner @@ -16,7 +17,7 @@ class BannerApi(Resource): language = request.args.get("language", "en-US") # Build base query for enabled banners - base_query = db.session.query(ExporleBanner).where(ExporleBanner.status == "enabled") + base_query = db.session.query(ExporleBanner).where(ExporleBanner.status == BannerStatus.ENABLED) # Try to get banners in the requested language banners = base_query.where(ExporleBanner.language == language).order_by(ExporleBanner.sort).all() diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index 22b24271c6..eb579da5d4 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -70,7 +70,14 @@ def handle_webhook(webhook_id: str): @bp.route("/webhook-debug/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]) def handle_webhook_debug(webhook_id: str): - """Handle webhook debug calls without triggering production workflow execution.""" + """Handle webhook debug calls without triggering production workflow execution. + + The debug webhook endpoint is only for draft inspection flows. It never enqueues + Celery work for the published workflow; instead it dispatches an in-memory debug + event to an active Variable Inspector listener. Returning a clear error when no + listener is registered prevents a misleading 200 response for requests that are + effectively dropped. + """ try: webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True) if error: @@ -94,11 +101,32 @@ def handle_webhook_debug(webhook_id: str): "method": webhook_data.get("method"), }, ) - TriggerDebugEventBus.dispatch( + dispatch_count = TriggerDebugEventBus.dispatch( tenant_id=webhook_trigger.tenant_id, event=event, pool_key=pool_key, ) + if dispatch_count == 0: + logger.warning( + "Webhook debug request dropped without an active listener for webhook %s (tenant=%s, app=%s, node=%s)", + webhook_trigger.webhook_id, + webhook_trigger.tenant_id, + webhook_trigger.app_id, + webhook_trigger.node_id, + ) + return ( + jsonify( + { + "error": "No active debug listener", + "message": ( + "The webhook debug URL only works while the Variable Inspector is listening. " + "Use the published webhook URL to execute the workflow in Celery." + ), + "execution_url": webhook_trigger.webhook_url, + } + ), + 409, + ) response_data, status_code = WebhookService.generate_webhook_response(node_config) return jsonify(response_data), status_code diff --git a/api/core/datasource/datasource_file_manager.py b/api/core/datasource/datasource_file_manager.py index 5971c1e013..24243add17 100644 --- a/api/core/datasource/datasource_file_manager.py +++ b/api/core/datasource/datasource_file_manager.py @@ -15,6 +15,7 @@ from configs import dify_config from core.helper import ssrf_proxy from extensions.ext_database import db from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import MessageFile, UploadFile from models.tools import ToolFile @@ -81,7 +82,7 @@ class DatasourceFileManager: upload_file = UploadFile( tenant_id=tenant_id, - storage_type=dify_config.STORAGE_TYPE, + storage_type=StorageType(dify_config.STORAGE_TYPE), key=filepath, name=present_filename, size=len(file_binary), diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py index 0279725ff2..a9f2300ba2 100644 --- a/api/core/entities/provider_configuration.py +++ b/api/core/entities/provider_configuration.py @@ -30,6 +30,7 @@ from dify_graph.model_runtime.model_providers.__base.ai_model import AIModel from dify_graph.model_runtime.model_providers.model_provider_factory import ModelProviderFactory from libs.datetime_utils import naive_utc_now from models.engine import db +from models.enums import CredentialSourceType from models.provider import ( LoadBalancingModelConfig, Provider, @@ -546,7 +547,7 @@ class ProviderConfiguration(BaseModel): self._update_load_balancing_configs_with_credential( credential_id=credential_id, credential_record=credential_record, - credential_source="provider", + credential_source=CredentialSourceType.PROVIDER, session=session, ) except Exception: @@ -623,7 +624,7 @@ class ProviderConfiguration(BaseModel): LoadBalancingModelConfig.tenant_id == self.tenant_id, LoadBalancingModelConfig.provider_name.in_(self._get_provider_names()), LoadBalancingModelConfig.credential_id == credential_id, - LoadBalancingModelConfig.credential_source_type == "provider", + LoadBalancingModelConfig.credential_source_type == CredentialSourceType.PROVIDER, ) lb_configs_using_credential = session.execute(lb_stmt).scalars().all() try: @@ -1043,7 +1044,7 @@ class ProviderConfiguration(BaseModel): self._update_load_balancing_configs_with_credential( credential_id=credential_id, credential_record=credential_record, - credential_source="custom_model", + credential_source=CredentialSourceType.CUSTOM_MODEL, session=session, ) except Exception: @@ -1073,7 +1074,7 @@ class ProviderConfiguration(BaseModel): LoadBalancingModelConfig.tenant_id == self.tenant_id, LoadBalancingModelConfig.provider_name.in_(self._get_provider_names()), LoadBalancingModelConfig.credential_id == credential_id, - LoadBalancingModelConfig.credential_source_type == "custom_model", + LoadBalancingModelConfig.credential_source_type == CredentialSourceType.CUSTOM_MODEL, ) lb_configs_using_credential = session.execute(lb_stmt).scalars().all() @@ -1421,12 +1422,12 @@ class ProviderConfiguration(BaseModel): preferred_model_provider = s.execute(stmt).scalars().first() if preferred_model_provider: - preferred_model_provider.preferred_provider_type = provider_type.value + preferred_model_provider.preferred_provider_type = provider_type else: preferred_model_provider = TenantPreferredModelProvider( tenant_id=self.tenant_id, provider_name=self.provider.provider, - preferred_provider_type=provider_type.value, + preferred_provider_type=provider_type, ) s.add(preferred_model_provider) s.commit() @@ -1711,7 +1712,7 @@ class ProviderConfiguration(BaseModel): provider_model_lb_configs = [ config for config in model_setting.load_balancing_configs - if config.credential_source_type != "custom_model" + if config.credential_source_type != CredentialSourceType.CUSTOM_MODEL ] load_balancing_enabled = model_setting.load_balancing_enabled @@ -1769,7 +1770,7 @@ class ProviderConfiguration(BaseModel): custom_model_lb_configs = [ config for config in model_setting.load_balancing_configs - if config.credential_source_type != "provider" + if config.credential_source_type != CredentialSourceType.PROVIDER ] load_balancing_enabled = model_setting.load_balancing_enabled diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index ed34922346..3c3fbd6dd2 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -195,7 +195,7 @@ class ProviderManager: preferred_provider_type_record = provider_name_to_preferred_model_provider_records_dict.get(provider_name) if preferred_provider_type_record: - preferred_provider_type = ProviderType.value_of(preferred_provider_type_record.preferred_provider_type) + preferred_provider_type = preferred_provider_type_record.preferred_provider_type elif dify_config.EDITION == "CLOUD" and system_configuration.enabled: preferred_provider_type = ProviderType.SYSTEM elif custom_configuration.provider or custom_configuration.models: diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index 7f6ecc3d3f..d7ea03efee 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -68,9 +68,12 @@ class SegmentRecord(TypedDict): class DefaultRetrievalModelDict(TypedDict): - search_method: RetrievalMethod | str + search_method: RetrievalMethod reranking_enable: bool reranking_model: RerankingModelDict + reranking_mode: NotRequired[str] + weights: NotRequired[WeightsDict | None] + score_threshold: NotRequired[float] top_k: int score_threshold_enabled: bool diff --git a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py index 5ab03a1380..d29d62c93f 100644 --- a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py +++ b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py @@ -5,6 +5,7 @@ This module provides integration with Weaviate vector database for storing and r document embeddings used in retrieval-augmented generation workflows. """ +import atexit import datetime import json import logging @@ -37,6 +38,32 @@ _weaviate_client: weaviate.WeaviateClient | None = None _weaviate_client_lock = threading.Lock() +def _shutdown_weaviate_client() -> None: + """ + Best-effort shutdown hook to close the module-level Weaviate client. + + This is registered with atexit so that HTTP/gRPC resources are released + when the Python interpreter exits. + """ + global _weaviate_client + + # Ensure thread-safety when accessing the shared client instance + with _weaviate_client_lock: + client = _weaviate_client + _weaviate_client = None + + if client is not None: + try: + client.close() + except Exception: + # Best-effort cleanup; log at debug level and ignore errors. + logger.debug("Failed to close Weaviate client during shutdown", exc_info=True) + + +# Register the shutdown hook once per process. +atexit.register(_shutdown_weaviate_client) + + class WeaviateConfig(BaseModel): """ Configuration model for Weaviate connection settings. @@ -85,18 +112,6 @@ class WeaviateVector(BaseVector): self._client = self._init_client(config) self._attributes = attributes - def __del__(self): - """ - Destructor to properly close the Weaviate client connection. - Prevents connection leaks and resource warnings. - """ - if hasattr(self, "_client") and self._client is not None: - try: - self._client.close() - except Exception as e: - # Ignore errors during cleanup as object is being destroyed - logger.warning("Error closing Weaviate client %s", e, exc_info=True) - def _init_client(self, config: WeaviateConfig) -> weaviate.WeaviateClient: """ Initializes and returns a connected Weaviate client. diff --git a/api/core/rag/extractor/firecrawl/firecrawl_app.py b/api/core/rag/extractor/firecrawl/firecrawl_app.py index 5d6223db06..371f7b0865 100644 --- a/api/core/rag/extractor/firecrawl/firecrawl_app.py +++ b/api/core/rag/extractor/firecrawl/firecrawl_app.py @@ -1,12 +1,38 @@ import json import time -from typing import Any, cast +from typing import Any, NotRequired, cast import httpx +from typing_extensions import TypedDict from extensions.ext_storage import storage +class FirecrawlDocumentData(TypedDict): + title: str | None + description: str | None + source_url: str | None + markdown: str | None + + +class CrawlStatusResponse(TypedDict): + status: str + total: int | None + current: int | None + data: list[FirecrawlDocumentData] + + +class MapResponse(TypedDict): + success: bool + links: list[str] + + +class SearchResponse(TypedDict): + success: bool + data: list[dict[str, Any]] + warning: NotRequired[str] + + class FirecrawlApp: def __init__(self, api_key=None, base_url=None): self.api_key = api_key @@ -14,7 +40,7 @@ class FirecrawlApp: if self.api_key is None and self.base_url == "https://api.firecrawl.dev": raise ValueError("No API key provided") - def scrape_url(self, url, params=None) -> dict[str, Any]: + def scrape_url(self, url, params=None) -> FirecrawlDocumentData: # Documentation: https://docs.firecrawl.dev/api-reference/endpoint/scrape headers = self._prepare_headers() json_data = { @@ -32,9 +58,7 @@ class FirecrawlApp: return self._extract_common_fields(data) elif response.status_code in {402, 409, 500, 429, 408}: self._handle_error(response, "scrape URL") - return {} # Avoid additional exception after handling error - else: - raise Exception(f"Failed to scrape URL. Status code: {response.status_code}") + raise Exception(f"Failed to scrape URL. Status code: {response.status_code}") def crawl_url(self, url, params=None) -> str: # Documentation: https://docs.firecrawl.dev/api-reference/endpoint/crawl-post @@ -51,7 +75,7 @@ class FirecrawlApp: self._handle_error(response, "start crawl job") return "" # unreachable - def map(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + def map(self, url: str, params: dict[str, Any] | None = None) -> MapResponse: # Documentation: https://docs.firecrawl.dev/api-reference/endpoint/map headers = self._prepare_headers() json_data: dict[str, Any] = {"url": url, "integration": "dify"} @@ -60,14 +84,12 @@ class FirecrawlApp: json_data.update(params) response = self._post_request(self._build_url("v2/map"), json_data, headers) if response.status_code == 200: - return cast(dict[str, Any], response.json()) + return cast(MapResponse, response.json()) elif response.status_code in {402, 409, 500, 429, 408}: self._handle_error(response, "start map job") - return {} - else: - raise Exception(f"Failed to start map job. Status code: {response.status_code}") + raise Exception(f"Failed to start map job. Status code: {response.status_code}") - def check_crawl_status(self, job_id) -> dict[str, Any]: + def check_crawl_status(self, job_id) -> CrawlStatusResponse: headers = self._prepare_headers() response = self._get_request(self._build_url(f"v2/crawl/{job_id}"), headers) if response.status_code == 200: @@ -77,7 +99,7 @@ class FirecrawlApp: if total == 0: raise Exception("Failed to check crawl status. Error: No page found") data = crawl_status_response.get("data", []) - url_data_list = [] + url_data_list: list[FirecrawlDocumentData] = [] for item in data: if isinstance(item, dict) and "metadata" in item and "markdown" in item: url_data = self._extract_common_fields(item) @@ -95,13 +117,15 @@ class FirecrawlApp: return self._format_crawl_status_response( crawl_status_response.get("status"), crawl_status_response, [] ) - else: - self._handle_error(response, "check crawl status") - return {} # unreachable + self._handle_error(response, "check crawl status") + raise RuntimeError("unreachable: _handle_error always raises") def _format_crawl_status_response( - self, status: str, crawl_status_response: dict[str, Any], url_data_list: list[dict[str, Any]] - ) -> dict[str, Any]: + self, + status: str, + crawl_status_response: dict[str, Any], + url_data_list: list[FirecrawlDocumentData], + ) -> CrawlStatusResponse: return { "status": status, "total": crawl_status_response.get("total"), @@ -109,7 +133,7 @@ class FirecrawlApp: "data": url_data_list, } - def _extract_common_fields(self, item: dict[str, Any]) -> dict[str, Any]: + def _extract_common_fields(self, item: dict[str, Any]) -> FirecrawlDocumentData: return { "title": item.get("metadata", {}).get("title"), "description": item.get("metadata", {}).get("description"), @@ -117,7 +141,7 @@ class FirecrawlApp: "markdown": item.get("markdown"), } - def _prepare_headers(self) -> dict[str, Any]: + def _prepare_headers(self) -> dict[str, str]: return {"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"} def _build_url(self, path: str) -> str: @@ -150,10 +174,10 @@ class FirecrawlApp: error_message = response.text or "Unknown error occurred" raise Exception(f"Failed to {action}. Status code: {response.status_code}. Error: {error_message}") # type: ignore[return] - def search(self, query: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + def search(self, query: str, params: dict[str, Any] | None = None) -> SearchResponse: # Documentation: https://docs.firecrawl.dev/api-reference/endpoint/search headers = self._prepare_headers() - json_data = { + json_data: dict[str, Any] = { "query": query, "limit": 5, "lang": "en", @@ -170,12 +194,10 @@ class FirecrawlApp: json_data.update(params) response = self._post_request(self._build_url("v2/search"), json_data, headers) if response.status_code == 200: - response_data = response.json() + response_data: SearchResponse = response.json() if not response_data.get("success"): raise Exception(f"Search failed. Error: {response_data.get('warning', 'Unknown error')}") - return cast(dict[str, Any], response_data) + return response_data elif response.status_code in {402, 409, 500, 429, 408}: self._handle_error(response, "perform search") - return {} # Avoid additional exception after handling error - else: - raise Exception(f"Failed to perform search. Status code: {response.status_code}") + raise Exception(f"Failed to perform search. Status code: {response.status_code}") diff --git a/api/core/rag/extractor/pdf_extractor.py b/api/core/rag/extractor/pdf_extractor.py index 6aabcac704..9abdb31325 100644 --- a/api/core/rag/extractor/pdf_extractor.py +++ b/api/core/rag/extractor/pdf_extractor.py @@ -15,6 +15,7 @@ from core.rag.extractor.extractor_base import BaseExtractor from core.rag.models.document import Document from extensions.ext_database import db from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole from models.model import UploadFile @@ -150,7 +151,7 @@ class PdfExtractor(BaseExtractor): # save file to db upload_file = UploadFile( tenant_id=self._tenant_id, - storage_type=dify_config.STORAGE_TYPE, + storage_type=StorageType(dify_config.STORAGE_TYPE), key=file_key, name=file_key, size=len(img_bytes), diff --git a/api/core/rag/extractor/watercrawl/client.py b/api/core/rag/extractor/watercrawl/client.py index 7cf6c4d289..e8da866870 100644 --- a/api/core/rag/extractor/watercrawl/client.py +++ b/api/core/rag/extractor/watercrawl/client.py @@ -1,10 +1,11 @@ import json from collections.abc import Generator -from typing import Union +from typing import Any, Union from urllib.parse import urljoin import httpx from httpx import Response +from typing_extensions import TypedDict from core.rag.extractor.watercrawl.exceptions import ( WaterCrawlAuthenticationError, @@ -13,6 +14,27 @@ from core.rag.extractor.watercrawl.exceptions import ( ) +class SpiderOptions(TypedDict): + max_depth: int + page_limit: int + allowed_domains: list[str] + exclude_paths: list[str] + include_paths: list[str] + + +class PageOptions(TypedDict): + exclude_tags: list[str] + include_tags: list[str] + wait_time: int + include_html: bool + only_main_content: bool + include_links: bool + timeout: int + accept_cookies_selector: str + locale: str + actions: list[Any] + + class BaseAPIClient: def __init__(self, api_key, base_url): self.api_key = api_key @@ -121,9 +143,9 @@ class WaterCrawlAPIClient(BaseAPIClient): def create_crawl_request( self, url: Union[list, str] | None = None, - spider_options: dict | None = None, - page_options: dict | None = None, - plugin_options: dict | None = None, + spider_options: SpiderOptions | None = None, + page_options: PageOptions | None = None, + plugin_options: dict[str, Any] | None = None, ): data = { # 'urls': url if isinstance(url, list) else [url], @@ -176,8 +198,8 @@ class WaterCrawlAPIClient(BaseAPIClient): def scrape_url( self, url: str, - page_options: dict | None = None, - plugin_options: dict | None = None, + page_options: PageOptions | None = None, + plugin_options: dict[str, Any] | None = None, sync: bool = True, prefetched: bool = True, ): diff --git a/api/core/rag/extractor/watercrawl/provider.py b/api/core/rag/extractor/watercrawl/provider.py index fe983aa86a..81c19005db 100644 --- a/api/core/rag/extractor/watercrawl/provider.py +++ b/api/core/rag/extractor/watercrawl/provider.py @@ -2,16 +2,39 @@ from collections.abc import Generator from datetime import datetime from typing import Any -from core.rag.extractor.watercrawl.client import WaterCrawlAPIClient +from typing_extensions import TypedDict + +from core.rag.extractor.watercrawl.client import PageOptions, SpiderOptions, WaterCrawlAPIClient + + +class WatercrawlDocumentData(TypedDict): + title: str | None + description: str | None + source_url: str | None + markdown: str | None + + +class CrawlJobResponse(TypedDict): + status: str + job_id: str | None + + +class WatercrawlCrawlStatusResponse(TypedDict): + status: str + job_id: str | None + total: int + current: int + data: list[WatercrawlDocumentData] + time_consuming: float class WaterCrawlProvider: def __init__(self, api_key, base_url: str | None = None): self.client = WaterCrawlAPIClient(api_key, base_url) - def crawl_url(self, url, options: dict | Any | None = None): + def crawl_url(self, url: str, options: dict[str, Any] | None = None) -> CrawlJobResponse: options = options or {} - spider_options = { + spider_options: SpiderOptions = { "max_depth": 1, "page_limit": 1, "allowed_domains": [], @@ -25,7 +48,7 @@ class WaterCrawlProvider: spider_options["exclude_paths"] = options.get("excludes", "").split(",") if options.get("excludes") else [] wait_time = options.get("wait_time", 1000) - page_options = { + page_options: PageOptions = { "exclude_tags": options.get("exclude_tags", "").split(",") if options.get("exclude_tags") else [], "include_tags": options.get("include_tags", "").split(",") if options.get("include_tags") else [], "wait_time": max(1000, wait_time), # minimum wait time is 1 second @@ -41,9 +64,9 @@ class WaterCrawlProvider: return {"status": "active", "job_id": result.get("uuid")} - def get_crawl_status(self, crawl_request_id): + def get_crawl_status(self, crawl_request_id: str) -> WatercrawlCrawlStatusResponse: response = self.client.get_crawl_request(crawl_request_id) - data = [] + data: list[WatercrawlDocumentData] = [] if response["status"] in ["new", "running"]: status = "active" else: @@ -67,7 +90,7 @@ class WaterCrawlProvider: "time_consuming": time_consuming, } - def get_crawl_url_data(self, job_id, url) -> dict | None: + def get_crawl_url_data(self, job_id: str, url: str) -> WatercrawlDocumentData | None: if not job_id: return self.scrape_url(url) @@ -82,11 +105,11 @@ class WaterCrawlProvider: return None - def scrape_url(self, url: str): + def scrape_url(self, url: str) -> WatercrawlDocumentData: response = self.client.scrape_url(url=url, sync=True, prefetched=True) return self._structure_data(response) - def _structure_data(self, result_object: dict): + def _structure_data(self, result_object: dict[str, Any]) -> WatercrawlDocumentData: if isinstance(result_object.get("result", {}), str): raise ValueError("Invalid result object. Expected a dictionary.") @@ -98,7 +121,9 @@ class WaterCrawlProvider: "markdown": result_object.get("result", {}).get("markdown"), } - def _get_results(self, crawl_request_id: str, query_params: dict | None = None) -> Generator[dict, None, None]: + def _get_results( + self, crawl_request_id: str, query_params: dict | None = None + ) -> Generator[WatercrawlDocumentData, None, None]: page = 0 page_size = 100 diff --git a/api/core/rag/extractor/word_extractor.py b/api/core/rag/extractor/word_extractor.py index d6b6ca35be..f44e7492cb 100644 --- a/api/core/rag/extractor/word_extractor.py +++ b/api/core/rag/extractor/word_extractor.py @@ -21,6 +21,7 @@ from core.rag.extractor.extractor_base import BaseExtractor from core.rag.models.document import Document from extensions.ext_database import db from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole from models.model import UploadFile @@ -112,7 +113,7 @@ class WordExtractor(BaseExtractor): # save file to db upload_file = UploadFile( tenant_id=self.tenant_id, - storage_type=dify_config.STORAGE_TYPE, + storage_type=StorageType(dify_config.STORAGE_TYPE), key=file_key, name=file_key, size=0, @@ -140,7 +141,7 @@ class WordExtractor(BaseExtractor): # save file to db upload_file = UploadFile( tenant_id=self.tenant_id, - storage_type=dify_config.STORAGE_TYPE, + storage_type=StorageType(dify_config.STORAGE_TYPE), key=file_key, name=file_key, size=0, diff --git a/api/core/rag/index_processor/index_processor.py b/api/core/rag/index_processor/index_processor.py index a7c42c5a4e..d9145023ac 100644 --- a/api/core/rag/index_processor/index_processor.py +++ b/api/core/rag/index_processor/index_processor.py @@ -9,6 +9,7 @@ from flask import current_app from sqlalchemy import delete, func, select from core.db.session_factory import session_factory +from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict from core.workflow.nodes.knowledge_index.exc import KnowledgeIndexNodeError from core.workflow.nodes.knowledge_index.protocols import Preview, PreviewItem, QaPreview from models.dataset import Dataset, Document, DocumentSegment @@ -51,7 +52,7 @@ class IndexProcessor: original_document_id: str, chunks: Mapping[str, Any], batch: Any, - summary_index_setting: dict | None = None, + summary_index_setting: SummaryIndexSettingDict | None = None, ): with session_factory.create_session() as session: document = session.query(Document).filter_by(id=document_id).first() @@ -131,7 +132,12 @@ class IndexProcessor: } def get_preview_output( - self, chunks: Any, dataset_id: str, document_id: str, chunk_structure: str, summary_index_setting: dict | None + self, + chunks: Any, + dataset_id: str, + document_id: str, + chunk_structure: str, + summary_index_setting: SummaryIndexSettingDict | None, ) -> Preview: doc_language = None with session_factory.create_session() as session: diff --git a/api/core/rag/index_processor/index_processor_base.py b/api/core/rag/index_processor/index_processor_base.py index 9e0557e1ff..a435dfc46a 100644 --- a/api/core/rag/index_processor/index_processor_base.py +++ b/api/core/rag/index_processor/index_processor_base.py @@ -7,10 +7,11 @@ import os import re from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, NotRequired, Optional from urllib.parse import unquote, urlparse import httpx +from typing_extensions import TypedDict from configs import dify_config from core.entities.knowledge_entities import PreviewDetail @@ -36,6 +37,13 @@ if TYPE_CHECKING: from core.model_manager import ModelInstance +class SummaryIndexSettingDict(TypedDict): + enable: bool + model_name: NotRequired[str] + model_provider_name: NotRequired[str] + summary_prompt: NotRequired[str] + + class BaseIndexProcessor(ABC): """Interface for extract files.""" @@ -52,7 +60,7 @@ class BaseIndexProcessor(ABC): self, tenant_id: str, preview_texts: list[PreviewDetail], - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, doc_language: str | None = None, ) -> list[PreviewDetail]: """ diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py index 4b767860dc..80163b1707 100644 --- a/api/core/rag/index_processor/processor/paragraph_index_processor.py +++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py @@ -23,7 +23,7 @@ from core.rag.extractor.entity.extract_setting import ExtractSetting from core.rag.extractor.extract_processor import ExtractProcessor from core.rag.index_processor.constant.doc_type import DocType from core.rag.index_processor.constant.index_type import IndexStructureType -from core.rag.index_processor.index_processor_base import BaseIndexProcessor +from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict from core.rag.models.document import AttachmentDocument, Document, MultimodalGeneralStructureChunk from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.utils.text_processing_utils import remove_leading_symbols @@ -279,7 +279,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): self, tenant_id: str, preview_texts: list[PreviewDetail], - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, doc_language: str | None = None, ) -> list[PreviewDetail]: """ @@ -363,7 +363,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor): def generate_summary( tenant_id: str, text: str, - summary_index_setting: dict | None = None, + summary_index_setting: SummaryIndexSettingDict | None = None, segment_id: str | None = None, document_language: str | None = None, ) -> tuple[str, LLMUsage]: diff --git a/api/core/rag/index_processor/processor/parent_child_index_processor.py b/api/core/rag/index_processor/processor/parent_child_index_processor.py index 702a63b561..df0761ca73 100644 --- a/api/core/rag/index_processor/processor/parent_child_index_processor.py +++ b/api/core/rag/index_processor/processor/parent_child_index_processor.py @@ -19,7 +19,7 @@ from core.rag.extractor.entity.extract_setting import ExtractSetting from core.rag.extractor.extract_processor import ExtractProcessor from core.rag.index_processor.constant.doc_type import DocType from core.rag.index_processor.constant.index_type import IndexStructureType -from core.rag.index_processor.index_processor_base import BaseIndexProcessor +from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict from core.rag.models.document import AttachmentDocument, ChildDocument, Document, ParentChildStructureChunk from core.rag.retrieval.retrieval_methods import RetrievalMethod from extensions.ext_database import db @@ -362,7 +362,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor): self, tenant_id: str, preview_texts: list[PreviewDetail], - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, doc_language: str | None = None, ) -> list[PreviewDetail]: """ diff --git a/api/core/rag/index_processor/processor/qa_index_processor.py b/api/core/rag/index_processor/processor/qa_index_processor.py index d56f69ca75..62f88b7760 100644 --- a/api/core/rag/index_processor/processor/qa_index_processor.py +++ b/api/core/rag/index_processor/processor/qa_index_processor.py @@ -22,7 +22,7 @@ from core.rag.docstore.dataset_docstore import DatasetDocumentStore from core.rag.extractor.entity.extract_setting import ExtractSetting from core.rag.extractor.extract_processor import ExtractProcessor from core.rag.index_processor.constant.index_type import IndexStructureType -from core.rag.index_processor.index_processor_base import BaseIndexProcessor +from core.rag.index_processor.index_processor_base import BaseIndexProcessor, SummaryIndexSettingDict from core.rag.models.document import AttachmentDocument, Document, QAStructureChunk from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.tools.utils.text_processing_utils import remove_leading_symbols @@ -245,7 +245,7 @@ class QAIndexProcessor(BaseIndexProcessor): self, tenant_id: str, preview_texts: list[PreviewDetail], - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, doc_language: str | None = None, ) -> list[PreviewDetail]: """ diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py index c44e9b847b..1096c69041 100644 --- a/api/core/rag/retrieval/dataset_retrieval.py +++ b/api/core/rag/retrieval/dataset_retrieval.py @@ -33,7 +33,7 @@ from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, Comp from core.prompt.simple_prompt_transform import ModelMode from core.rag.data_post_processor.data_post_processor import DataPostProcessor, RerankingModelDict, WeightsDict from core.rag.datasource.keyword.jieba.jieba_keyword_table_handler import JiebaKeywordTableHandler -from core.rag.datasource.retrieval_service import RetrievalService +from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.rag.entities.context_entities import DocumentContext from core.rag.entities.metadata_entities import Condition, MetadataCondition @@ -87,7 +87,7 @@ from models.enums import CreatorUserRole, DatasetQuerySource from services.external_knowledge_service import ExternalDatasetService from services.feature_service import FeatureService -default_retrieval_model: dict[str, Any] = { +default_retrieval_model: DefaultRetrievalModelDict = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, "reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""}, @@ -666,7 +666,11 @@ class DatasetRetrieval: document_ids_filter = document_ids else: return [] - retrieval_model_config = dataset.retrieval_model or default_retrieval_model + retrieval_model_config: DefaultRetrievalModelDict = ( + cast(DefaultRetrievalModelDict, dataset.retrieval_model) + if dataset.retrieval_model + else default_retrieval_model + ) # get top k top_k = retrieval_model_config["top_k"] @@ -1058,7 +1062,11 @@ class DatasetRetrieval: all_documents.append(document) else: # get retrieval model , if the model is not setting , using default - retrieval_model = dataset.retrieval_model or default_retrieval_model + retrieval_model: DefaultRetrievalModelDict = ( + cast(DefaultRetrievalModelDict, dataset.retrieval_model) + if dataset.retrieval_model + else default_retrieval_model + ) if dataset.indexing_technique == "economy": # use keyword table query @@ -1132,7 +1140,7 @@ class DatasetRetrieval: if retrieve_config.retrieve_strategy == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE: # get retrieval model config - default_retrieval_model = { + default_retrieval_model: DefaultRetrievalModelDict = { "search_method": RetrievalMethod.SEMANTIC_SEARCH, "reranking_enable": False, "reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""}, @@ -1141,7 +1149,11 @@ class DatasetRetrieval: } for dataset in available_datasets: - retrieval_model_config = dataset.retrieval_model or default_retrieval_model + retrieval_model_config: DefaultRetrievalModelDict = ( + cast(DefaultRetrievalModelDict, dataset.retrieval_model) + if dataset.retrieval_model + else default_retrieval_model + ) # get top k top_k = retrieval_model_config["top_k"] diff --git a/api/core/rag/summary_index/summary_index.py b/api/core/rag/summary_index/summary_index.py index 79d7821b4e..31d21dbeee 100644 --- a/api/core/rag/summary_index/summary_index.py +++ b/api/core/rag/summary_index/summary_index.py @@ -2,6 +2,7 @@ import concurrent.futures import logging from core.db.session_factory import session_factory +from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict from models.dataset import Dataset, Document, DocumentSegment, DocumentSegmentSummary from services.summary_index_service import SummaryIndexService from tasks.generate_summary_index_task import generate_summary_index_task @@ -11,7 +12,11 @@ logger = logging.getLogger(__name__) class SummaryIndex: def generate_and_vectorize_summary( - self, dataset_id: str, document_id: str, is_preview: bool, summary_index_setting: dict | None = None + self, + dataset_id: str, + document_id: str, + is_preview: bool, + summary_index_setting: SummaryIndexSettingDict | None = None, ) -> None: if is_preview: with session_factory.create_session() as session: diff --git a/api/core/trigger/constants.py b/api/core/trigger/constants.py index bfa45c3f2b..192faa2d3e 100644 --- a/api/core/trigger/constants.py +++ b/api/core/trigger/constants.py @@ -3,7 +3,6 @@ from typing import Final TRIGGER_WEBHOOK_NODE_TYPE: Final[str] = "trigger-webhook" TRIGGER_SCHEDULE_NODE_TYPE: Final[str] = "trigger-schedule" TRIGGER_PLUGIN_NODE_TYPE: Final[str] = "trigger-plugin" -TRIGGER_INFO_METADATA_KEY: Final[str] = "trigger_info" TRIGGER_NODE_TYPES: Final[frozenset[str]] = frozenset( { diff --git a/api/core/workflow/nodes/knowledge_index/entities.py b/api/core/workflow/nodes/knowledge_index/entities.py index 8b00746268..8d2e9bf3cb 100644 --- a/api/core/workflow/nodes/knowledge_index/entities.py +++ b/api/core/workflow/nodes/knowledge_index/entities.py @@ -2,6 +2,7 @@ from typing import Literal, Union from pydantic import BaseModel +from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from dify_graph.entities.base_node_data import BaseNodeData @@ -161,4 +162,4 @@ class KnowledgeIndexNodeData(BaseNodeData): chunk_structure: str index_chunk_variable_selector: list[str] indexing_technique: str | None = None - summary_index_setting: dict | None = None + summary_index_setting: SummaryIndexSettingDict | None = None diff --git a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py index 0a74847bc1..4ea9091c5b 100644 --- a/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py +++ b/api/core/workflow/nodes/knowledge_index/knowledge_index_node.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any from core.rag.index_processor.index_processor import IndexProcessor +from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict from core.rag.summary_index.summary_index import SummaryIndex from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from dify_graph.entities.graph_config import NodeConfigDict @@ -127,7 +128,7 @@ class KnowledgeIndexNode(Node[KnowledgeIndexNodeData]): is_preview: bool, batch: Any, chunks: Mapping[str, Any], - summary_index_setting: dict | None = None, + summary_index_setting: SummaryIndexSettingDict | None = None, ): if not document_id: raise KnowledgeIndexNodeError("document_id is required.") diff --git a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py index 2048a53064..118c2f2668 100644 --- a/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py +++ b/api/core/workflow/nodes/trigger_plugin/trigger_event_node.py @@ -1,7 +1,7 @@ from collections.abc import Mapping -from typing import Any, cast +from typing import Any -from core.trigger.constants import TRIGGER_INFO_METADATA_KEY, TRIGGER_PLUGIN_NODE_TYPE +from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from dify_graph.constants import SYSTEM_VARIABLE_NODE_ID from dify_graph.entities.workflow_node_execution import WorkflowNodeExecutionStatus from dify_graph.enums import NodeExecutionType, WorkflowNodeExecutionMetadataKey @@ -47,7 +47,7 @@ class TriggerEventNode(Node[TriggerEventNodeData]): # Get trigger data passed when workflow was triggered metadata: dict[WorkflowNodeExecutionMetadataKey, Any] = { - cast(WorkflowNodeExecutionMetadataKey, TRIGGER_INFO_METADATA_KEY): { + WorkflowNodeExecutionMetadataKey.TRIGGER_INFO: { "provider_id": self.node_data.provider_id, "event_name": self.node_data.event_name, "plugin_unique_identifier": self.node_data.plugin_unique_identifier, diff --git a/api/dify_graph/enums.py b/api/dify_graph/enums.py index 06653bebb6..cfb135cbb0 100644 --- a/api/dify_graph/enums.py +++ b/api/dify_graph/enums.py @@ -245,6 +245,9 @@ _END_STATE = frozenset( class WorkflowNodeExecutionMetadataKey(StrEnum): """ Node Run Metadata Key. + + Values in this enum are persisted as execution metadata and must stay in sync + with every node that writes `NodeRunResult.metadata`. """ TOTAL_TOKENS = "total_tokens" @@ -266,6 +269,7 @@ class WorkflowNodeExecutionMetadataKey(StrEnum): ERROR_STRATEGY = "error_strategy" # node in continue on error mode return the field LOOP_VARIABLE_MAP = "loop_variable_map" # single loop variable output DATASOURCE_INFO = "datasource_info" + TRIGGER_INFO = "trigger_info" COMPLETED_REASON = "completed_reason" # completed reason for loop node diff --git a/api/dify_graph/nodes/llm/llm_utils.py b/api/dify_graph/nodes/llm/llm_utils.py index 073dce232f..2be391a424 100644 --- a/api/dify_graph/nodes/llm/llm_utils.py +++ b/api/dify_graph/nodes/llm/llm_utils.py @@ -256,9 +256,13 @@ def fetch_prompt_messages( ): continue prompt_message_content.append(content_item) - if prompt_message_content: + if not prompt_message_content: + continue + if len(prompt_message_content) == 1 and prompt_message_content[0].type == PromptMessageContentType.TEXT: + prompt_message.content = prompt_message_content[0].data + else: prompt_message.content = prompt_message_content - filtered_prompt_messages.append(prompt_message) + filtered_prompt_messages.append(prompt_message) elif not prompt_message.is_empty(): filtered_prompt_messages.append(prompt_message) diff --git a/api/events/event_handlers/create_document_index.py b/api/events/event_handlers/create_document_index.py index 76de5a0740..b7e7a6e60f 100644 --- a/api/events/event_handlers/create_document_index.py +++ b/api/events/event_handlers/create_document_index.py @@ -3,6 +3,7 @@ import logging import time import click +from sqlalchemy import select from werkzeug.exceptions import NotFound from core.indexing_runner import DocumentIsPausedError, IndexingRunner @@ -24,13 +25,11 @@ def handle(sender, **kwargs): for document_id in document_ids: logger.info(click.style(f"Start process document: {document_id}", fg="green")) - document = ( - db.session.query(Document) - .where( + document = db.session.scalar( + select(Document).where( Document.id == document_id, Document.dataset_id == dataset_id, ) - .first() ) if not document: diff --git a/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py b/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py index b70c2183d2..4709534ae6 100644 --- a/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py +++ b/api/events/event_handlers/update_app_dataset_join_when_app_model_config_updated.py @@ -1,6 +1,6 @@ from typing import Any, cast -from sqlalchemy import select +from sqlalchemy import delete, select from events.app_event import app_model_config_was_updated from extensions.ext_database import db @@ -31,9 +31,9 @@ def handle(sender, **kwargs): if removed_dataset_ids: for dataset_id in removed_dataset_ids: - db.session.query(AppDatasetJoin).where( - AppDatasetJoin.app_id == app.id, AppDatasetJoin.dataset_id == dataset_id - ).delete() + db.session.execute( + delete(AppDatasetJoin).where(AppDatasetJoin.app_id == app.id, AppDatasetJoin.dataset_id == dataset_id) + ) if added_dataset_ids: for dataset_id in added_dataset_ids: diff --git a/api/events/event_handlers/update_app_dataset_join_when_app_published_workflow_updated.py b/api/events/event_handlers/update_app_dataset_join_when_app_published_workflow_updated.py index 92bc9db075..20852b818e 100644 --- a/api/events/event_handlers/update_app_dataset_join_when_app_published_workflow_updated.py +++ b/api/events/event_handlers/update_app_dataset_join_when_app_published_workflow_updated.py @@ -1,6 +1,6 @@ from typing import cast -from sqlalchemy import select +from sqlalchemy import delete, select from core.workflow.nodes.knowledge_retrieval.entities import KnowledgeRetrievalNodeData from dify_graph.nodes import BuiltinNodeTypes @@ -31,9 +31,9 @@ def handle(sender, **kwargs): if removed_dataset_ids: for dataset_id in removed_dataset_ids: - db.session.query(AppDatasetJoin).where( - AppDatasetJoin.app_id == app.id, AppDatasetJoin.dataset_id == dataset_id - ).delete() + db.session.execute( + delete(AppDatasetJoin).where(AppDatasetJoin.app_id == app.id, AppDatasetJoin.dataset_id == dataset_id) + ) if added_dataset_ids: for dataset_id in added_dataset_ids: diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index 74299956c0..02e50a90fc 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -3,6 +3,7 @@ import json import flask_login from flask import Response, request from flask_login import user_loaded_from_request, user_logged_in +from sqlalchemy import select from werkzeug.exceptions import NotFound, Unauthorized from configs import dify_config @@ -34,16 +35,15 @@ def load_user_from_request(request_from_flask_login): if admin_api_key and admin_api_key == auth_token: workspace_id = request.headers.get("X-WORKSPACE-ID") if workspace_id: - tenant_account_join = ( - db.session.query(Tenant, TenantAccountJoin) + tenant_account_join = db.session.execute( + select(Tenant, TenantAccountJoin) .where(Tenant.id == workspace_id) .where(TenantAccountJoin.tenant_id == Tenant.id) .where(TenantAccountJoin.role == "owner") - .one_or_none() - ) + ).one_or_none() if tenant_account_join: tenant, ta = tenant_account_join - account = db.session.query(Account).filter_by(id=ta.account_id).first() + account = db.session.scalar(select(Account).where(Account.id == ta.account_id)) if account: account.current_tenant = tenant return account @@ -70,7 +70,7 @@ def load_user_from_request(request_from_flask_login): end_user_id = decoded.get("end_user_id") if not end_user_id: raise Unauthorized("Invalid Authorization token.") - end_user = db.session.query(EndUser).where(EndUser.id == end_user_id).first() + end_user = db.session.scalar(select(EndUser).where(EndUser.id == end_user_id)) if not end_user: raise NotFound("End user not found.") return end_user @@ -80,7 +80,7 @@ def load_user_from_request(request_from_flask_login): decoded = PassportService().verify(auth_token) end_user_id = decoded.get("end_user_id") if end_user_id: - end_user = db.session.query(EndUser).where(EndUser.id == end_user_id).first() + end_user = db.session.scalar(select(EndUser).where(EndUser.id == end_user_id)) if not end_user: raise NotFound("End user not found.") return end_user @@ -90,11 +90,11 @@ def load_user_from_request(request_from_flask_login): server_code = request.view_args.get("server_code") if request.view_args else None if not server_code: raise Unauthorized("Invalid Authorization token.") - app_mcp_server = db.session.query(AppMCPServer).where(AppMCPServer.server_code == server_code).first() + app_mcp_server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.server_code == server_code).limit(1)) if not app_mcp_server: raise NotFound("App MCP server not found.") - end_user = ( - db.session.query(EndUser).where(EndUser.session_id == app_mcp_server.id, EndUser.type == "mcp").first() + end_user = db.session.scalar( + select(EndUser).where(EndUser.session_id == app_mcp_server.id, EndUser.type == "mcp").limit(1) ) if not end_user: raise NotFound("End user not found.") diff --git a/api/extensions/storage/opendal_storage.py b/api/extensions/storage/opendal_storage.py index 83c5c2d12f..96f5915ff0 100644 --- a/api/extensions/storage/opendal_storage.py +++ b/api/extensions/storage/opendal_storage.py @@ -32,7 +32,7 @@ class OpenDALStorage(BaseStorage): kwargs = kwargs or _get_opendal_kwargs(scheme=scheme) if scheme == "fs": - root = kwargs.get("root", "storage") + root = kwargs.setdefault("root", "storage") Path(root).mkdir(parents=True, exist_ok=True) retry_layer = opendal.layers.RetryLayer(max_times=3, factor=2.0, jitter=True) diff --git a/api/factories/file_factory.py b/api/factories/file_factory.py index ef55fe53c5..cb07ba58ae 100644 --- a/api/factories/file_factory.py +++ b/api/factories/file_factory.py @@ -424,13 +424,11 @@ def _build_from_datasource_file( datasource_file_id = mapping.get("datasource_file_id") if not datasource_file_id: raise ValueError(f"DatasourceFile {datasource_file_id} not found") - datasource_file = ( - db.session.query(UploadFile) - .where( + datasource_file = db.session.scalar( + select(UploadFile).where( UploadFile.id == datasource_file_id, UploadFile.tenant_id == tenant_id, ) - .first() ) if datasource_file is None: diff --git a/api/models/enums.py b/api/models/enums.py index 6af74cddc8..6499c5b443 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -11,6 +11,13 @@ class CreatorUserRole(StrEnum): ACCOUNT = "account" END_USER = "end_user" + @classmethod + def _missing_(cls, value): + if value == "end-user": + return cls.END_USER + else: + return super()._missing_(value) + class WorkflowRunTriggeredFrom(StrEnum): DEBUGGING = "debugging" diff --git a/api/models/model.py b/api/models/model.py index fe70fcd401..45d9c501ae 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -23,13 +23,22 @@ from core.tools.signature import sign_tool_file from dify_graph.enums import WorkflowExecutionStatus from dify_graph.file import FILE_MODEL_IDENTITY, File, FileTransferMethod from dify_graph.file import helpers as file_helpers +from extensions.storage.storage_type import StorageType from libs.helper import generate_string # type: ignore[import-not-found] from libs.uuid_utils import uuidv7 from .account import Account, Tenant from .base import Base, TypeBase, gen_uuidv4_string from .engine import db -from .enums import AppMCPServerStatus, AppStatus, ConversationStatus, CreatorUserRole, MessageStatus +from .enums import ( + AppMCPServerStatus, + AppStatus, + BannerStatus, + ConversationStatus, + CreatorUserRole, + MessageChainType, + MessageStatus, +) from .provider_ids import GenericProviderID from .types import EnumText, LongText, StringUUID @@ -925,8 +934,11 @@ class ExporleBanner(TypeBase): content: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False) link: Mapped[str] = mapped_column(String(255), nullable=False) sort: Mapped[int] = mapped_column(sa.Integer, nullable=False) - status: Mapped[str] = mapped_column( - sa.String(255), nullable=False, server_default=sa.text("'enabled'::character varying"), default="enabled" + status: Mapped[BannerStatus] = mapped_column( + EnumText(BannerStatus, length=255), + nullable=False, + server_default=sa.text("'enabled'::character varying"), + default=BannerStatus.ENABLED, ) created_at: Mapped[datetime] = mapped_column( sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False @@ -2097,7 +2109,7 @@ class UploadFile(Base): # The `server_default` serves as a fallback mechanism. id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - storage_type: Mapped[str] = mapped_column(String(255), nullable=False) + storage_type: Mapped[StorageType] = mapped_column(EnumText(StorageType, length=255), nullable=False) key: Mapped[str] = mapped_column(String(255), nullable=False) name: Mapped[str] = mapped_column(String(255), nullable=False) size: Mapped[int] = mapped_column(sa.Integer, nullable=False) @@ -2141,7 +2153,7 @@ class UploadFile(Base): self, *, tenant_id: str, - storage_type: str, + storage_type: StorageType, key: str, name: str, size: int, @@ -2206,7 +2218,7 @@ class MessageChain(TypeBase): StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False ) message_id: Mapped[str] = mapped_column(StringUUID, nullable=False) - type: Mapped[str] = mapped_column(String(255), nullable=False) + type: Mapped[MessageChainType] = mapped_column(EnumText(MessageChainType, length=255), nullable=False) input: Mapped[str | None] = mapped_column(LongText, nullable=True) output: Mapped[str | None] = mapped_column(LongText, nullable=True) created_at: Mapped[datetime] = mapped_column( diff --git a/api/models/provider.py b/api/models/provider.py index 7cefdbaba5..afeee20b1e 100644 --- a/api/models/provider.py +++ b/api/models/provider.py @@ -13,6 +13,7 @@ from libs.uuid_utils import uuidv7 from .base import TypeBase from .engine import db +from .enums import CredentialSourceType, PaymentStatus from .types import EnumText, LongText, StringUUID @@ -209,7 +210,7 @@ class TenantPreferredModelProvider(TypeBase): ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) provider_name: Mapped[str] = mapped_column(String(255), nullable=False) - preferred_provider_type: Mapped[str] = mapped_column(String(40), nullable=False) + preferred_provider_type: Mapped[ProviderType] = mapped_column(EnumText(ProviderType, length=40), nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) @@ -237,7 +238,9 @@ class ProviderOrder(TypeBase): quantity: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=text("1")) currency: Mapped[str | None] = mapped_column(String(40)) total_amount: Mapped[int | None] = mapped_column(sa.Integer) - payment_status: Mapped[str] = mapped_column(String(40), nullable=False, server_default=text("'wait_pay'")) + payment_status: Mapped[PaymentStatus] = mapped_column( + EnumText(PaymentStatus, length=40), nullable=False, server_default=text("'wait_pay'") + ) paid_at: Mapped[datetime | None] = mapped_column(DateTime) pay_failed_at: Mapped[datetime | None] = mapped_column(DateTime) refunded_at: Mapped[datetime | None] = mapped_column(DateTime) @@ -300,7 +303,9 @@ class LoadBalancingModelConfig(TypeBase): name: Mapped[str] = mapped_column(String(255), nullable=False) encrypted_config: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None) credential_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) - credential_source_type: Mapped[str | None] = mapped_column(String(40), nullable=True, default=None) + credential_source_type: Mapped[CredentialSourceType | None] = mapped_column( + EnumText(CredentialSourceType, length=40), nullable=True, default=None + ) enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=text("true"), default=True) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, server_default=func.current_timestamp(), init=False diff --git a/api/models/workflow.py b/api/models/workflow.py index 9bb249481f..e7b20d0e65 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -22,14 +22,14 @@ from sqlalchemy import ( from sqlalchemy.orm import Mapped, mapped_column from typing_extensions import deprecated -from core.trigger.constants import TRIGGER_INFO_METADATA_KEY, TRIGGER_PLUGIN_NODE_TYPE +from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from dify_graph.constants import ( CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID, ) from dify_graph.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from dify_graph.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause -from dify_graph.enums import BuiltinNodeTypes, NodeType, WorkflowExecutionStatus +from dify_graph.enums import BuiltinNodeTypes, NodeType, WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey from dify_graph.file.constants import maybe_file_object from dify_graph.file.models import File from dify_graph.variables import utils as variable_utils @@ -936,8 +936,11 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo elif self.node_type == BuiltinNodeTypes.DATASOURCE and "datasource_info" in execution_metadata: datasource_info = execution_metadata["datasource_info"] extras["icon"] = datasource_info.get("icon") - elif self.node_type == TRIGGER_PLUGIN_NODE_TYPE and TRIGGER_INFO_METADATA_KEY in execution_metadata: - trigger_info = execution_metadata[TRIGGER_INFO_METADATA_KEY] or {} + elif ( + self.node_type == TRIGGER_PLUGIN_NODE_TYPE + and WorkflowNodeExecutionMetadataKey.TRIGGER_INFO in execution_metadata + ): + trigger_info = execution_metadata[WorkflowNodeExecutionMetadataKey.TRIGGER_INFO] or {} provider_id = trigger_info.get("provider_id") if provider_id: extras["icon"] = TriggerManager.get_trigger_plugin_icon( diff --git a/api/pyproject.toml b/api/pyproject.toml index 31b778ab8c..f824fe7c23 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dify-api" -version = "1.13.1" +version = "1.13.2" requires-python = ">=3.11,<3.13" dependencies = [ diff --git a/api/pytest.ini b/api/pytest.ini index 588dafe7eb..4d5d0ab6e0 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -1,6 +1,6 @@ [pytest] pythonpath = . -addopts = --cov=./api --cov-report=json --import-mode=importlib +addopts = --cov=./api --cov-report=json --import-mode=importlib --cov-branch --cov-report=xml env = ANTHROPIC_API_KEY = sk-ant-api11-IamNotARealKeyJustForMockTestKawaiiiiiiiiii-NotBaka-ASkksz AZURE_OPENAI_API_BASE = https://difyai-openai.openai.azure.com diff --git a/api/schedule/check_upgradable_plugin_task.py b/api/schedule/check_upgradable_plugin_task.py index 13d2f24ca0..cf223f6e9e 100644 --- a/api/schedule/check_upgradable_plugin_task.py +++ b/api/schedule/check_upgradable_plugin_task.py @@ -3,6 +3,7 @@ import math import time import click +from sqlalchemy import select import app from core.helper.marketplace import fetch_global_plugin_manifest @@ -28,17 +29,15 @@ def check_upgradable_plugin_task(): now_seconds_of_day = time.time() % 86400 - 30 # we assume the tz is UTC click.echo(click.style(f"Now seconds of day: {now_seconds_of_day}", fg="green")) - strategies = ( - db.session.query(TenantPluginAutoUpgradeStrategy) - .where( + strategies = db.session.scalars( + select(TenantPluginAutoUpgradeStrategy).where( TenantPluginAutoUpgradeStrategy.upgrade_time_of_day >= now_seconds_of_day, TenantPluginAutoUpgradeStrategy.upgrade_time_of_day < now_seconds_of_day + AUTO_UPGRADE_MINIMAL_CHECKING_INTERVAL, TenantPluginAutoUpgradeStrategy.strategy_setting != TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED, ) - .all() - ) + ).all() total_strategies = len(strategies) click.echo(click.style(f"Total strategies: {total_strategies}", fg="green")) diff --git a/api/schedule/clean_embedding_cache_task.py b/api/schedule/clean_embedding_cache_task.py index 2b74fb2dd0..04c954875f 100644 --- a/api/schedule/clean_embedding_cache_task.py +++ b/api/schedule/clean_embedding_cache_task.py @@ -2,7 +2,7 @@ import datetime import time import click -from sqlalchemy import text +from sqlalchemy import select, text from sqlalchemy.exc import SQLAlchemyError import app @@ -19,14 +19,12 @@ def clean_embedding_cache_task(): thirty_days_ago = datetime.datetime.now() - datetime.timedelta(days=clean_days) while True: try: - embedding_ids = ( - db.session.query(Embedding.id) + embedding_ids = db.session.scalars( + select(Embedding.id) .where(Embedding.created_at < thirty_days_ago) .order_by(Embedding.created_at.desc()) .limit(100) - .all() - ) - embedding_ids = [embedding_id[0] for embedding_id in embedding_ids] + ).all() except SQLAlchemyError: raise if embedding_ids: diff --git a/api/schedule/clean_unused_datasets_task.py b/api/schedule/clean_unused_datasets_task.py index d9fb6a24f1..0b0fc1b229 100644 --- a/api/schedule/clean_unused_datasets_task.py +++ b/api/schedule/clean_unused_datasets_task.py @@ -3,7 +3,7 @@ import time from typing import TypedDict import click -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.exc import SQLAlchemyError import app @@ -51,7 +51,7 @@ def clean_unused_datasets_task(): try: # Subquery for counting new documents document_subquery_new = ( - db.session.query(Document.dataset_id, func.count(Document.id).label("document_count")) + select(Document.dataset_id, func.count(Document.id).label("document_count")) .where( Document.indexing_status == "completed", Document.enabled == True, @@ -64,7 +64,7 @@ def clean_unused_datasets_task(): # Subquery for counting old documents document_subquery_old = ( - db.session.query(Document.dataset_id, func.count(Document.id).label("document_count")) + select(Document.dataset_id, func.count(Document.id).label("document_count")) .where( Document.indexing_status == "completed", Document.enabled == True, @@ -142,8 +142,8 @@ def clean_unused_datasets_task(): index_processor.clean(dataset, None) # Update document - db.session.query(Document).filter_by(dataset_id=dataset.id).update( - {Document.enabled: False} + db.session.execute( + update(Document).where(Document.dataset_id == dataset.id).values(enabled=False) ) db.session.commit() click.echo(click.style(f"Cleaned unused dataset {dataset.id} from db success!", fg="green")) diff --git a/api/schedule/create_tidb_serverless_task.py b/api/schedule/create_tidb_serverless_task.py index ed46c1c70a..8b9d973d6d 100644 --- a/api/schedule/create_tidb_serverless_task.py +++ b/api/schedule/create_tidb_serverless_task.py @@ -1,6 +1,7 @@ import time import click +from sqlalchemy import func, select import app from configs import dify_config @@ -20,7 +21,7 @@ def create_tidb_serverless_task(): try: # check the number of idle tidb serverless idle_tidb_serverless_number = ( - db.session.query(TidbAuthBinding).where(TidbAuthBinding.active == False).count() + db.session.scalar(select(func.count(TidbAuthBinding.id)).where(TidbAuthBinding.active == False)) or 0 ) if idle_tidb_serverless_number >= tidb_serverless_number: break diff --git a/api/schedule/mail_clean_document_notify_task.py b/api/schedule/mail_clean_document_notify_task.py index d738bf46fa..8479cdfb0c 100644 --- a/api/schedule/mail_clean_document_notify_task.py +++ b/api/schedule/mail_clean_document_notify_task.py @@ -49,16 +49,18 @@ def mail_clean_document_notify_task(): if plan != CloudPlan.SANDBOX: knowledge_details = [] # check tenant - tenant = db.session.query(Tenant).where(Tenant.id == tenant_id).first() + tenant = db.session.scalar(select(Tenant).where(Tenant.id == tenant_id)) if not tenant: continue # check current owner - current_owner_join = ( - db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, role="owner").first() + current_owner_join = db.session.scalar( + select(TenantAccountJoin) + .where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner") + .limit(1) ) if not current_owner_join: continue - account = db.session.query(Account).where(Account.id == current_owner_join.account_id).first() + account = db.session.scalar(select(Account).where(Account.id == current_owner_join.account_id)) if not account: continue @@ -71,7 +73,7 @@ def mail_clean_document_notify_task(): ) for dataset_id, document_ids in dataset_auto_dataset_map.items(): - dataset = db.session.query(Dataset).where(Dataset.id == dataset_id).first() + dataset = db.session.scalar(select(Dataset).where(Dataset.id == dataset_id)) if dataset: document_count = len(document_ids) knowledge_details.append(rf"Knowledge base {dataset.name}: {document_count} documents") diff --git a/api/services/file_service.py b/api/services/file_service.py index ecb30faaa8..a7060f3b92 100644 --- a/api/services/file_service.py +++ b/api/services/file_service.py @@ -23,6 +23,7 @@ from core.rag.extractor.extract_processor import ExtractProcessor from dify_graph.file import helpers as file_helpers from extensions.ext_database import db from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from libs.helper import extract_tenant_id from models import Account @@ -93,7 +94,7 @@ class FileService: # save file to db upload_file = UploadFile( tenant_id=current_tenant_id or "", - storage_type=dify_config.STORAGE_TYPE, + storage_type=StorageType(dify_config.STORAGE_TYPE), key=file_key, name=filename, size=file_size, @@ -152,7 +153,7 @@ class FileService: # save file to db upload_file = UploadFile( tenant_id=tenant_id, - storage_type=dify_config.STORAGE_TYPE, + storage_type=StorageType(dify_config.STORAGE_TYPE), key=file_key, name=text_name, size=len(text), diff --git a/api/services/model_load_balancing_service.py b/api/services/model_load_balancing_service.py index 2133dc5b3a..bf3b6db3ed 100644 --- a/api/services/model_load_balancing_service.py +++ b/api/services/model_load_balancing_service.py @@ -19,6 +19,7 @@ from dify_graph.model_runtime.entities.provider_entities import ( from dify_graph.model_runtime.model_providers.model_provider_factory import ModelProviderFactory from extensions.ext_database import db from libs.datetime_utils import naive_utc_now +from models.enums import CredentialSourceType from models.provider import LoadBalancingModelConfig, ProviderCredential, ProviderModelCredential logger = logging.getLogger(__name__) @@ -103,9 +104,9 @@ class ModelLoadBalancingService: is_load_balancing_enabled = True if config_from == "predefined-model": - credential_source_type = "provider" + credential_source_type = CredentialSourceType.PROVIDER else: - credential_source_type = "custom_model" + credential_source_type = CredentialSourceType.CUSTOM_MODEL # Get load balancing configurations load_balancing_configs = ( @@ -421,7 +422,11 @@ class ModelLoadBalancingService: raise ValueError("Invalid load balancing config name") if credential_id: - credential_source = "provider" if config_from == "predefined-model" else "custom_model" + credential_source = ( + CredentialSourceType.PROVIDER + if config_from == "predefined-model" + else CredentialSourceType.CUSTOM_MODEL + ) assert credential_record is not None load_balancing_model_config = LoadBalancingModelConfig( tenant_id=tenant_id, diff --git a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py index c5775d9a37..f996db11dc 100644 --- a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py +++ b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py @@ -49,7 +49,7 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase): response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0)) if response.status_code != 200: raise ValueError( - f"fetch pipeline template detail failed," + "fetch pipeline template detail failed," + f" status_code: {response.status_code}," + f" response: {response.text[:1000]}" ) diff --git a/api/services/summary_index_service.py b/api/services/summary_index_service.py index 13a6363bc3..943dfc972b 100644 --- a/api/services/summary_index_service.py +++ b/api/services/summary_index_service.py @@ -12,6 +12,7 @@ from core.db.session_factory import session_factory from core.model_manager import ModelManager from core.rag.datasource.vdb.vector_factory import Vector from core.rag.index_processor.constant.doc_type import DocType +from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict from core.rag.models.document import Document from dify_graph.model_runtime.entities.llm_entities import LLMUsage from dify_graph.model_runtime.entities.model_entities import ModelType @@ -30,7 +31,7 @@ class SummaryIndexService: def generate_summary_for_segment( segment: DocumentSegment, dataset: Dataset, - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, ) -> tuple[str, LLMUsage]: """ Generate summary for a single segment. @@ -600,7 +601,7 @@ class SummaryIndexService: def generate_and_vectorize_summary( segment: DocumentSegment, dataset: Dataset, - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, ) -> DocumentSegmentSummary: """ Generate summary for a segment and vectorize it. @@ -705,7 +706,7 @@ class SummaryIndexService: def generate_summaries_for_document( dataset: Dataset, document: DatasetDocument, - summary_index_setting: dict, + summary_index_setting: SummaryIndexSettingDict, segment_ids: list[str] | None = None, only_parent_chunks: bool = False, ) -> list[DocumentSegmentSummary]: diff --git a/api/services/website_service.py b/api/services/website_service.py index 15ec4657d9..b2917ba152 100644 --- a/api/services/website_service.py +++ b/api/services/website_service.py @@ -9,7 +9,7 @@ import httpx from flask_login import current_user from core.helper import encrypter -from core.rag.extractor.firecrawl.firecrawl_app import FirecrawlApp +from core.rag.extractor.firecrawl.firecrawl_app import CrawlStatusResponse, FirecrawlApp, FirecrawlDocumentData from core.rag.extractor.watercrawl.provider import WaterCrawlProvider from extensions.ext_redis import redis_client from extensions.ext_storage import storage @@ -216,8 +216,10 @@ class WebsiteService: "max_depth": request.options.max_depth, "use_sitemap": request.options.use_sitemap, } - return WaterCrawlProvider(api_key=api_key, base_url=config.get("base_url")).crawl_url( - url=request.url, options=options + return dict( + WaterCrawlProvider(api_key=api_key, base_url=config.get("base_url")).crawl_url( + url=request.url, options=options + ) ) @classmethod @@ -270,13 +272,13 @@ class WebsiteService: @classmethod def _get_firecrawl_status(cls, job_id: str, api_key: str, config: dict) -> dict[str, Any]: firecrawl_app = FirecrawlApp(api_key=api_key, base_url=config.get("base_url")) - result = firecrawl_app.check_crawl_status(job_id) - crawl_status_data = { - "status": result.get("status", "active"), + result: CrawlStatusResponse = firecrawl_app.check_crawl_status(job_id) + crawl_status_data: dict[str, Any] = { + "status": result["status"], "job_id": job_id, - "total": result.get("total", 0), - "current": result.get("current", 0), - "data": result.get("data", []), + "total": result["total"] or 0, + "current": result["current"] or 0, + "data": result["data"], } if crawl_status_data["status"] == "completed": website_crawl_time_cache_key = f"website_crawl_{job_id}" @@ -289,8 +291,8 @@ class WebsiteService: return crawl_status_data @classmethod - def _get_watercrawl_status(cls, job_id: str, api_key: str, config: dict) -> dict[str, Any]: - return WaterCrawlProvider(api_key, config.get("base_url")).get_crawl_status(job_id) + def _get_watercrawl_status(cls, job_id: str, api_key: str, config: dict[str, Any]) -> dict[str, Any]: + return dict(WaterCrawlProvider(api_key, config.get("base_url")).get_crawl_status(job_id)) @classmethod def _get_jinareader_status(cls, job_id: str, api_key: str) -> dict[str, Any]: @@ -343,7 +345,7 @@ class WebsiteService: @classmethod def _get_firecrawl_url_data(cls, job_id: str, url: str, api_key: str, config: dict) -> dict[str, Any] | None: - crawl_data: list[dict[str, Any]] | None = None + crawl_data: list[FirecrawlDocumentData] | None = None file_key = "website_files/" + job_id + ".txt" if storage.exists(file_key): stored_data = storage.load_once(file_key) @@ -352,19 +354,22 @@ class WebsiteService: else: firecrawl_app = FirecrawlApp(api_key=api_key, base_url=config.get("base_url")) result = firecrawl_app.check_crawl_status(job_id) - if result.get("status") != "completed": + if result["status"] != "completed": raise ValueError("Crawl job is not completed") - crawl_data = result.get("data") + crawl_data = result["data"] if crawl_data: for item in crawl_data: - if item.get("source_url") == url: + if item["source_url"] == url: return dict(item) return None @classmethod - def _get_watercrawl_url_data(cls, job_id: str, url: str, api_key: str, config: dict) -> dict[str, Any] | None: - return WaterCrawlProvider(api_key, config.get("base_url")).get_crawl_url_data(job_id, url) + def _get_watercrawl_url_data( + cls, job_id: str, url: str, api_key: str, config: dict[str, Any] + ) -> dict[str, Any] | None: + result = WaterCrawlProvider(api_key, config.get("base_url")).get_crawl_url_data(job_id, url) + return dict(result) if result is not None else None @classmethod def _get_jinareader_url_data(cls, job_id: str, url: str, api_key: str) -> dict[str, Any] | None: @@ -416,8 +421,8 @@ class WebsiteService: def _scrape_with_firecrawl(cls, request: ScrapeRequest, api_key: str, config: dict) -> dict[str, Any]: firecrawl_app = FirecrawlApp(api_key=api_key, base_url=config.get("base_url")) params = {"onlyMainContent": request.only_main_content} - return firecrawl_app.scrape_url(url=request.url, params=params) + return dict(firecrawl_app.scrape_url(url=request.url, params=params)) @classmethod - def _scrape_with_watercrawl(cls, request: ScrapeRequest, api_key: str, config: dict) -> dict[str, Any]: - return WaterCrawlProvider(api_key=api_key, base_url=config.get("base_url")).scrape_url(request.url) + def _scrape_with_watercrawl(cls, request: ScrapeRequest, api_key: str, config: dict[str, Any]) -> dict[str, Any]: + return dict(WaterCrawlProvider(api_key=api_key, base_url=config.get("base_url")).scrape_url(request.url)) diff --git a/api/tests/integration_tests/factories/test_storage_key_loader.py b/api/tests/integration_tests/factories/test_storage_key_loader.py index b4e3a0e4de..db4bbc1ca1 100644 --- a/api/tests/integration_tests/factories/test_storage_key_loader.py +++ b/api/tests/integration_tests/factories/test_storage_key_loader.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from dify_graph.file import File, FileTransferMethod, FileType from extensions.ext_database import db +from extensions.storage.storage_type import StorageType from factories.file_factory import StorageKeyLoader from models import ToolFile, UploadFile from models.enums import CreatorUserRole @@ -53,7 +54,7 @@ class TestStorageKeyLoader(unittest.TestCase): upload_file = UploadFile( tenant_id=tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=storage_key, name="test_file.txt", size=1024, @@ -288,7 +289,7 @@ class TestStorageKeyLoader(unittest.TestCase): # Create upload file for other tenant (but don't add to cleanup list) upload_file_other = UploadFile( tenant_id=other_tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key="other_tenant_key", name="other_file.txt", size=1024, diff --git a/api/tests/integration_tests/services/test_workflow_draft_variable_service.py b/api/tests/integration_tests/services/test_workflow_draft_variable_service.py index b6aeb54cca..9d3a869691 100644 --- a/api/tests/integration_tests/services/test_workflow_draft_variable_service.py +++ b/api/tests/integration_tests/services/test_workflow_draft_variable_service.py @@ -13,6 +13,7 @@ from dify_graph.variables.types import SegmentType from dify_graph.variables.variables import StringVariable from extensions.ext_database import db from extensions.ext_storage import storage +from extensions.storage.storage_type import StorageType from factories.variable_factory import build_segment from libs import datetime_utils from models.enums import CreatorUserRole @@ -347,7 +348,7 @@ class TestDraftVariableLoader(unittest.TestCase): # Create an upload file record upload_file = UploadFile( tenant_id=self._test_tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test_offload_{uuid.uuid4()}.json", name="test_offload.json", size=len(content_bytes), @@ -450,7 +451,7 @@ class TestDraftVariableLoader(unittest.TestCase): # Create upload file record upload_file = UploadFile( tenant_id=self._test_tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test_integration_{uuid.uuid4()}.txt", name="test_integration.txt", size=len(content_bytes), diff --git a/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py index 988313e68d..bc83c6cc12 100644 --- a/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -6,6 +6,7 @@ from sqlalchemy import delete from core.db.session_factory import session_factory from dify_graph.variables.segments import StringSegment +from extensions.storage.storage_type import StorageType from models import Tenant from models.enums import CreatorUserRole from models.model import App, UploadFile @@ -197,7 +198,7 @@ class TestDeleteDraftVariablesWithOffloadIntegration: with session_factory.create_session() as session: upload_file1 = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key="test/file1.json", name="file1.json", size=1024, @@ -210,7 +211,7 @@ class TestDeleteDraftVariablesWithOffloadIntegration: ) upload_file2 = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key="test/file2.json", name="file2.json", size=2048, @@ -430,7 +431,7 @@ class TestDeleteDraftVariablesSessionCommit: with session_factory.create_session() as session: upload_file1 = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key="test/file1.json", name="file1.json", size=1024, @@ -443,7 +444,7 @@ class TestDeleteDraftVariablesSessionCommit: ) upload_file2 = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key="test/file2.json", name="file2.json", size=2048, diff --git a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py index cb7cd37a3f..8e70fc0bb0 100644 --- a/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py +++ b/api/tests/test_containers_integration_tests/factories/test_storage_key_loader.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from dify_graph.file import File, FileTransferMethod, FileType from extensions.ext_database import db +from extensions.storage.storage_type import StorageType from factories.file_factory import StorageKeyLoader from models import ToolFile, UploadFile from models.enums import CreatorUserRole @@ -53,7 +54,7 @@ class TestStorageKeyLoader(unittest.TestCase): upload_file = UploadFile( tenant_id=tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=storage_key, name="test_file.txt", size=1024, @@ -289,7 +290,7 @@ class TestStorageKeyLoader(unittest.TestCase): # Create upload file for other tenant (but don't add to cleanup list) upload_file_other = UploadFile( tenant_id=other_tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key="other_tenant_key", name="other_file.txt", size=1024, diff --git a/api/tests/test_containers_integration_tests/services/document_service_status.py b/api/tests/test_containers_integration_tests/services/document_service_status.py index 251f17dd03..f995ac7bef 100644 --- a/api/tests/test_containers_integration_tests/services/document_service_status.py +++ b/api/tests/test_containers_integration_tests/services/document_service_status.py @@ -13,6 +13,7 @@ from uuid import uuid4 import pytest +from extensions.storage.storage_type import StorageType from models import Account from models.dataset import Dataset, Document from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus @@ -198,7 +199,7 @@ class DocumentStatusTestDataFactory: """ upload_file = UploadFile( tenant_id=tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"uploads/{uuid4()}", name=name, size=128, diff --git a/api/tests/test_containers_integration_tests/services/test_document_service_rename_document.py b/api/tests/test_containers_integration_tests/services/test_document_service_rename_document.py index b159af0090..bffa520ce6 100644 --- a/api/tests/test_containers_integration_tests/services/test_document_service_rename_document.py +++ b/api/tests/test_containers_integration_tests/services/test_document_service_rename_document.py @@ -7,6 +7,7 @@ from uuid import uuid4 import pytest +from extensions.storage.storage_type import StorageType from models import Account from models.dataset import Dataset, Document from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom @@ -83,7 +84,7 @@ def make_upload_file(db_session_with_containers, tenant_id: str, file_id: str, n """Persist an upload file row referenced by document.data_source_info.""" upload_file = UploadFile( tenant_id=tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"uploads/{uuid4()}", name=name, size=128, diff --git a/api/tests/test_containers_integration_tests/services/test_file_service.py b/api/tests/test_containers_integration_tests/services/test_file_service.py index 50f5b7a8c0..42dbdef1c9 100644 --- a/api/tests/test_containers_integration_tests/services/test_file_service.py +++ b/api/tests/test_containers_integration_tests/services/test_file_service.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import NotFound from configs import dify_config +from extensions.storage.storage_type import StorageType from models import Account, Tenant from models.enums import CreatorUserRole from models.model import EndUser, UploadFile @@ -140,7 +141,7 @@ class TestFileService: upload_file = UploadFile( tenant_id=account.current_tenant_id if hasattr(account, "current_tenant_id") else str(fake.uuid4()), - storage_type="local", + storage_type=StorageType.LOCAL, key=f"upload_files/test/{fake.uuid4()}.txt", name="test_file.txt", size=1024, diff --git a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py index ef1f31d36b..7b5157fa61 100644 --- a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py +++ b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from enums.cloud_plan import CloudPlan from extensions.ext_redis import redis_client from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole -from models.enums import DataSourceType +from models.enums import DataSourceType, MessageChainType from models.model import ( App, AppAnnotationHitHistory, @@ -236,7 +236,7 @@ class TestMessagesCleanServiceIntegration: # MessageChain chain = MessageChain( message_id=message.id, - type="system", + type=MessageChainType.SYSTEM, input=json.dumps({"test": "input"}), output=json.dumps({"test": "output"}), ) diff --git a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py index 6adefd59be..210d9eb39e 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py @@ -13,6 +13,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session +from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import Dataset, Document, DocumentSegment @@ -209,7 +210,7 @@ class TestBatchCleanDocumentTask: upload_file = UploadFile( tenant_id=account.current_tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test_files/{fake.file_name()}", name=fake.file_name(), size=1024, diff --git a/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py b/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py index ebe5ff1d96..202ccb0098 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_batch_create_segment_to_index_task.py @@ -19,6 +19,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session +from extensions.storage.storage_type import StorageType from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import Dataset, Document, DocumentSegment from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus @@ -203,7 +204,7 @@ class TestBatchCreateSegmentToIndexTask: upload_file = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test_files/{fake.file_name()}", name=fake.file_name(), size=1024, diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py index 638752cf8b..1cd698b870 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_dataset_task.py @@ -18,6 +18,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session +from extensions.storage.storage_type import StorageType from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import ( AppDatasetJoin, @@ -254,7 +255,7 @@ class TestCleanDatasetTask: upload_file = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test_files/{fake.file_name()}", name=fake.file_name(), size=1024, @@ -925,7 +926,7 @@ class TestCleanDatasetTask: special_filename = f"test_file_{special_content}.txt" upload_file = UploadFile( tenant_id=tenant.id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test_files/{special_filename}", name=special_filename, size=1024, diff --git a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py index 182c9ef882..5bded4d670 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_remove_app_and_related_data_task.py @@ -6,6 +6,7 @@ import pytest from core.db.session_factory import session_factory from dify_graph.variables.segments import StringSegment from dify_graph.variables.types import SegmentType +from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from models import Tenant from models.enums import CreatorUserRole @@ -78,7 +79,7 @@ def _create_offload_data(db_session_with_containers, *, tenant_id: str, app_id: for i in range(count): upload_file = UploadFile( tenant_id=tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key=f"test/file-{uuid.uuid4()}-{i}.json", name=f"file-{i}.json", size=1024 + i, diff --git a/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py b/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py new file mode 100644 index 0000000000..34a1941c39 --- /dev/null +++ b/api/tests/test_containers_integration_tests/test_opendal_fs_default_root.py @@ -0,0 +1,56 @@ +from pathlib import Path + +from extensions.storage.opendal_storage import OpenDALStorage + + +class TestOpenDALFsDefaultRoot: + """Test that OpenDALStorage with scheme='fs' works correctly when no root is provided.""" + + def test_fs_without_root_uses_default(self, tmp_path, monkeypatch): + """When no root is specified, the default 'storage' should be used and passed to the Operator.""" + # Change to tmp_path so the default "storage" dir is created there + monkeypatch.chdir(tmp_path) + # Ensure no OPENDAL_FS_ROOT env var is set + monkeypatch.delenv("OPENDAL_FS_ROOT", raising=False) + + storage = OpenDALStorage(scheme="fs") + + # The default directory should have been created + assert (tmp_path / "storage").is_dir() + # The storage should be functional + storage.save("test_default_root.txt", b"hello") + assert storage.exists("test_default_root.txt") + assert storage.load_once("test_default_root.txt") == b"hello" + + # Cleanup + storage.delete("test_default_root.txt") + + def test_fs_with_explicit_root(self, tmp_path): + """When root is explicitly provided, it should be used.""" + custom_root = str(tmp_path / "custom_storage") + storage = OpenDALStorage(scheme="fs", root=custom_root) + + assert Path(custom_root).is_dir() + storage.save("test_explicit_root.txt", b"world") + assert storage.exists("test_explicit_root.txt") + assert storage.load_once("test_explicit_root.txt") == b"world" + + # Cleanup + storage.delete("test_explicit_root.txt") + + def test_fs_with_env_var_root(self, tmp_path, monkeypatch): + """When OPENDAL_FS_ROOT env var is set, it should be picked up via _get_opendal_kwargs.""" + env_root = str(tmp_path / "env_storage") + monkeypatch.setenv("OPENDAL_FS_ROOT", env_root) + # Ensure .env file doesn't interfere + monkeypatch.chdir(tmp_path) + + storage = OpenDALStorage(scheme="fs") + + assert Path(env_root).is_dir() + storage.save("test_env_root.txt", b"env_data") + assert storage.exists("test_env_root.txt") + assert storage.load_once("test_env_root.txt") == b"env_data" + + # Cleanup + storage.delete("test_env_root.txt") diff --git a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py index f9fc2ac397..0ee76e504b 100644 --- a/api/tests/unit_tests/controllers/console/datasets/test_datasets.py +++ b/api/tests/unit_tests/controllers/console/datasets/test_datasets.py @@ -28,6 +28,7 @@ from controllers.console.datasets.datasets import ( from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.provider_manager import ProviderManager +from extensions.storage.storage_type import StorageType from models.enums import CreatorUserRole from models.model import ApiToken, UploadFile from services.dataset_service import DatasetPermissionService, DatasetService @@ -1121,7 +1122,7 @@ class TestDatasetIndexingEstimateApi: def _upload_file(self, *, tenant_id: str = "tenant-1", file_id: str = "file-1") -> UploadFile: upload_file = UploadFile( tenant_id=tenant_id, - storage_type="local", + storage_type=StorageType.LOCAL, key="key", name="name.txt", size=1, diff --git a/api/tests/unit_tests/controllers/console/explore/test_banner.py b/api/tests/unit_tests/controllers/console/explore/test_banner.py index 0606219356..4414f1eb5f 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_banner.py +++ b/api/tests/unit_tests/controllers/console/explore/test_banner.py @@ -2,6 +2,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch import controllers.console.explore.banner as banner_module +from models.enums import BannerStatus def unwrap(func): @@ -20,7 +21,7 @@ class TestBannerApi: banner.content = {"text": "hello"} banner.link = "https://example.com" banner.sort = 1 - banner.status = "enabled" + banner.status = BannerStatus.ENABLED banner.created_at = datetime(2024, 1, 1) query = MagicMock() @@ -54,7 +55,7 @@ class TestBannerApi: banner.content = {"text": "fallback"} banner.link = None banner.sort = 1 - banner.status = "enabled" + banner.status = BannerStatus.ENABLED banner.created_at = None query = MagicMock() diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py index 61fce3ed97..95c2f5cf92 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py @@ -39,14 +39,21 @@ class TestHitTestingPayload: def test_payload_with_all_fields(self): """Test payload with all optional fields.""" + retrieval_model_data = { + "search_method": "semantic_search", + "reranking_enable": False, + "score_threshold_enabled": False, + "top_k": 5, + } payload = HitTestingPayload( query="test query", - retrieval_model={"top_k": 5}, + retrieval_model=retrieval_model_data, external_retrieval_model={"provider": "openai"}, attachment_ids=["att_1", "att_2"], ) assert payload.query == "test query" - assert payload.retrieval_model == {"top_k": 5} + assert payload.retrieval_model is not None + assert payload.retrieval_model.top_k == 5 assert payload.external_retrieval_model == {"provider": "openai"} assert payload.attachment_ids == ["att_1", "att_2"] @@ -134,7 +141,13 @@ class TestHitTestingApiPost: mock_dataset_svc.get_dataset.return_value = mock_dataset mock_dataset_svc.check_dataset_permission.return_value = None - retrieval_model = {"search_method": "semantic", "top_k": 10, "score_threshold": 0.8} + retrieval_model = { + "search_method": "semantic_search", + "reranking_enable": False, + "score_threshold_enabled": True, + "top_k": 10, + "score_threshold": 0.8, + } mock_hit_svc.retrieve.return_value = {"query": "complex query", "records": []} mock_hit_svc.hit_testing_args_check.return_value = None @@ -152,7 +165,11 @@ class TestHitTestingApiPost: assert response["query"] == "complex query" call_kwargs = mock_hit_svc.retrieve.call_args - assert call_kwargs.kwargs.get("retrieval_model") == retrieval_model + # retrieval_model is serialized via model_dump, verify key fields + passed_retrieval_model = call_kwargs.kwargs.get("retrieval_model") + assert passed_retrieval_model is not None + assert passed_retrieval_model["search_method"] == "semantic_search" + assert passed_retrieval_model["top_k"] == 10 @patch("controllers.service_api.dataset.hit_testing.service_api_ns") @patch("controllers.console.datasets.hit_testing_base.DatasetService") diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py index d633365f2b..91c793d292 100644 --- a/api/tests/unit_tests/controllers/trigger/test_webhook.py +++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py @@ -23,6 +23,7 @@ def mock_jsonify(): class DummyWebhookTrigger: webhook_id = "wh-1" + webhook_url = "http://localhost:5001/triggers/webhook/wh-1" tenant_id = "tenant-1" app_id = "app-1" node_id = "node-1" @@ -104,7 +105,32 @@ class TestHandleWebhookDebug: @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") @patch.object(module.WebhookService, "extract_and_validate_webhook_data") @patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1}) - @patch.object(module.TriggerDebugEventBus, "dispatch") + @patch.object(module.TriggerDebugEventBus, "dispatch", return_value=0) + def test_debug_requires_active_listener( + self, + mock_dispatch, + mock_build_inputs, + mock_extract, + mock_get, + ): + mock_get.return_value = (DummyWebhookTrigger(), None, "node_config") + mock_extract.return_value = {"method": "POST"} + + response, status = module.handle_webhook_debug("wh-1") + + assert status == 409 + assert response["error"] == "No active debug listener" + assert response["message"] == ( + "The webhook debug URL only works while the Variable Inspector is listening. " + "Use the published webhook URL to execute the workflow in Celery." + ) + assert response["execution_url"] == DummyWebhookTrigger.webhook_url + mock_dispatch.assert_called_once() + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data") + @patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1}) + @patch.object(module.TriggerDebugEventBus, "dispatch", return_value=1) @patch.object(module.WebhookService, "generate_webhook_response") def test_debug_success( self, diff --git a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py index a7c93242cd..7cd1fdf06b 100644 --- a/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py +++ b/api/tests/unit_tests/core/datasource/test_datasource_file_manager.py @@ -166,6 +166,7 @@ class TestDatasourceFileManager: # Setup mock_guess_ext.return_value = None # Cannot guess mock_uuid.return_value = MagicMock(hex="unique_hex") + mock_config.STORAGE_TYPE = "local" # Execute upload_file = DatasourceFileManager.create_file_by_raw( diff --git a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py index 5ebefcd8d2..95d58757f1 100644 --- a/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py +++ b/api/tests/unit_tests/core/entities/test_entities_provider_configuration.py @@ -35,6 +35,7 @@ from dify_graph.model_runtime.entities.provider_entities import ( ProviderCredentialSchema, ProviderEntity, ) +from models.enums import CredentialSourceType from models.provider import ProviderType from models.provider_ids import ModelProviderID @@ -409,7 +410,7 @@ def test_switch_preferred_provider_type_updates_existing_record_with_session() - configuration.switch_preferred_provider_type(ProviderType.SYSTEM, session=session) - assert existing_record.preferred_provider_type == ProviderType.SYSTEM.value + assert existing_record.preferred_provider_type == ProviderType.SYSTEM session.commit.assert_called_once() @@ -514,7 +515,7 @@ def test_get_custom_provider_models_sets_status_for_removed_credentials_and_inva id="lb-base", name="LB Base", credentials={}, - credential_source_type="provider", + credential_source_type=CredentialSourceType.PROVIDER, ) ], ), @@ -528,7 +529,7 @@ def test_get_custom_provider_models_sets_status_for_removed_credentials_and_inva id="lb-custom", name="LB Custom", credentials={}, - credential_source_type="custom_model", + credential_source_type=CredentialSourceType.CUSTOM_MODEL, ) ], ), @@ -826,7 +827,7 @@ def test_update_load_balancing_configs_updates_all_matching_configs() -> None: configuration._update_load_balancing_configs_with_credential( credential_id="cred-1", credential_record=credential_record, - credential_source="provider", + credential_source=CredentialSourceType.PROVIDER, session=session, ) @@ -844,7 +845,7 @@ def test_update_load_balancing_configs_returns_when_no_matching_configs() -> Non configuration._update_load_balancing_configs_with_credential( credential_id="cred-1", credential_record=SimpleNamespace(encrypted_config="{}", credential_name="Main"), - credential_source="provider", + credential_source=CredentialSourceType.PROVIDER, session=session, ) diff --git a/api/tests/unit_tests/core/rag/extractor/firecrawl/test_firecrawl.py b/api/tests/unit_tests/core/rag/extractor/firecrawl/test_firecrawl.py index d3040395be..2add12fd09 100644 --- a/api/tests/unit_tests/core/rag/extractor/firecrawl/test_firecrawl.py +++ b/api/tests/unit_tests/core/rag/extractor/firecrawl/test_firecrawl.py @@ -104,10 +104,11 @@ class TestFirecrawlApp: def test_map_known_error(self, mocker: MockerFixture): app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev") - mock_handle = mocker.patch.object(app, "_handle_error") + mock_handle = mocker.patch.object(app, "_handle_error", side_effect=Exception("map error")) mocker.patch("httpx.post", return_value=_response(409, {"error": "conflict"})) - assert app.map("https://example.com") == {} + with pytest.raises(Exception, match="map error"): + app.map("https://example.com") mock_handle.assert_called_once() def test_map_unknown_error_raises(self, mocker: MockerFixture): @@ -177,10 +178,11 @@ class TestFirecrawlApp: def test_check_crawl_status_non_200_uses_error_handler(self, mocker: MockerFixture): app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev") - mock_handle = mocker.patch.object(app, "_handle_error") + mock_handle = mocker.patch.object(app, "_handle_error", side_effect=Exception("crawl error")) mocker.patch("httpx.get", return_value=_response(500, {"error": "server"})) - assert app.check_crawl_status("job-1") == {} + with pytest.raises(Exception, match="crawl error"): + app.check_crawl_status("job-1") mock_handle.assert_called_once() def test_check_crawl_status_save_failure_raises(self, mocker: MockerFixture): @@ -272,9 +274,10 @@ class TestFirecrawlApp: def test_search_known_http_error(self, mocker: MockerFixture): app = FirecrawlApp(api_key="fc-key", base_url="https://custom.firecrawl.dev") - mock_handle = mocker.patch.object(app, "_handle_error") + mock_handle = mocker.patch.object(app, "_handle_error", side_effect=Exception("search error")) mocker.patch("httpx.post", return_value=_response(408, {"error": "timeout"})) - assert app.search("python") == {} + with pytest.raises(Exception, match="search error"): + app.search("python") mock_handle.assert_called_once() def test_search_unknown_http_error(self, mocker: MockerFixture): diff --git a/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py new file mode 100644 index 0000000000..618a498659 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py @@ -0,0 +1,106 @@ +from unittest import mock + +import pytest + +from core.model_manager import ModelInstance +from dify_graph.model_runtime.entities import ImagePromptMessageContent, PromptMessageRole, TextPromptMessageContent +from dify_graph.model_runtime.entities.message_entities import SystemPromptMessage +from dify_graph.nodes.llm import llm_utils +from dify_graph.nodes.llm.entities import LLMNodeChatModelMessage +from dify_graph.nodes.llm.exc import NoPromptFoundError +from dify_graph.runtime import VariablePool + + +def _fetch_prompt_messages_with_mocked_content(content): + variable_pool = VariablePool.empty() + model_instance = mock.MagicMock(spec=ModelInstance) + prompt_template = [ + LLMNodeChatModelMessage( + text="You are a classifier.", + role=PromptMessageRole.SYSTEM, + edition_type="basic", + ) + ] + + with ( + mock.patch( + "dify_graph.nodes.llm.llm_utils.fetch_model_schema", + return_value=mock.MagicMock(features=[]), + ), + mock.patch( + "dify_graph.nodes.llm.llm_utils.handle_list_messages", + return_value=[SystemPromptMessage(content=content)], + ), + mock.patch( + "dify_graph.nodes.llm.llm_utils.handle_memory_chat_mode", + return_value=[], + ), + ): + return llm_utils.fetch_prompt_messages( + sys_query=None, + sys_files=[], + context=None, + memory=None, + model_instance=model_instance, + prompt_template=prompt_template, + stop=["END"], + memory_config=None, + vision_enabled=False, + vision_detail=ImagePromptMessageContent.DETAIL.HIGH, + variable_pool=variable_pool, + jinja2_variables=[], + template_renderer=None, + ) + + +def test_fetch_prompt_messages_skips_messages_when_all_contents_are_filtered_out(): + with pytest.raises(NoPromptFoundError): + _fetch_prompt_messages_with_mocked_content( + [ + ImagePromptMessageContent( + format="url", + url="https://example.com/image.png", + mime_type="image/png", + ), + ] + ) + + +def test_fetch_prompt_messages_flattens_single_text_content_after_filtering_unsupported_multimodal_items(): + prompt_messages, stop = _fetch_prompt_messages_with_mocked_content( + [ + TextPromptMessageContent(data="You are a classifier."), + ImagePromptMessageContent( + format="url", + url="https://example.com/image.png", + mime_type="image/png", + ), + ] + ) + + assert stop == ["END"] + assert prompt_messages == [SystemPromptMessage(content="You are a classifier.")] + + +def test_fetch_prompt_messages_keeps_list_content_when_multiple_supported_items_remain(): + prompt_messages, stop = _fetch_prompt_messages_with_mocked_content( + [ + TextPromptMessageContent(data="You are"), + TextPromptMessageContent(data=" a classifier."), + ImagePromptMessageContent( + format="url", + url="https://example.com/image.png", + mime_type="image/png", + ), + ] + ) + + assert stop == ["END"] + assert prompt_messages == [ + SystemPromptMessage( + content=[ + TextPromptMessageContent(data="You are"), + TextPromptMessageContent(data=" a classifier."), + ] + ) + ] diff --git a/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py new file mode 100644 index 0000000000..9aeab0409e --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/trigger_plugin/test_trigger_event_node.py @@ -0,0 +1,63 @@ +from collections.abc import Mapping + +from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE +from core.workflow.nodes.trigger_plugin.trigger_event_node import TriggerEventNode +from dify_graph.entities import GraphInitParams +from dify_graph.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter +from dify_graph.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from dify_graph.runtime import GraphRuntimeState, VariablePool +from dify_graph.system_variable import SystemVariable +from tests.workflow_test_utils import build_test_graph_init_params + + +def _build_context(graph_config: Mapping[str, object]) -> tuple[GraphInitParams, GraphRuntimeState]: + init_params = build_test_graph_init_params( + graph_config=graph_config, + user_from="account", + invoke_from="debugger", + ) + runtime_state = GraphRuntimeState( + variable_pool=VariablePool( + system_variables=SystemVariable(user_id="user", files=[]), + user_inputs={"payload": "value"}, + ), + start_at=0.0, + ) + return init_params, runtime_state + + +def _build_node_config() -> NodeConfigDict: + return NodeConfigDictAdapter.validate_python( + { + "id": "node-1", + "data": { + "type": TRIGGER_PLUGIN_NODE_TYPE, + "title": "Trigger Event", + "plugin_id": "plugin-id", + "provider_id": "provider-id", + "event_name": "event-name", + "subscription_id": "subscription-id", + "plugin_unique_identifier": "plugin-unique-identifier", + "event_parameters": {}, + }, + } + ) + + +def test_trigger_event_node_run_populates_trigger_info_metadata() -> None: + init_params, runtime_state = _build_context(graph_config={}) + node = TriggerEventNode( + id="node-1", + config=_build_node_config(), + graph_init_params=init_params, + graph_runtime_state=runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.metadata[WorkflowNodeExecutionMetadataKey.TRIGGER_INFO] == { + "provider_id": "provider-id", + "event_name": "event-name", + "plugin_unique_identifier": "plugin-unique-identifier", + } diff --git a/api/tests/unit_tests/dify_graph/node_events/test_base.py b/api/tests/unit_tests/dify_graph/node_events/test_base.py new file mode 100644 index 0000000000..6d789abac0 --- /dev/null +++ b/api/tests/unit_tests/dify_graph/node_events/test_base.py @@ -0,0 +1,19 @@ +from dify_graph.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus +from dify_graph.node_events.base import NodeRunResult + + +def test_node_run_result_accepts_trigger_info_metadata() -> None: + result = NodeRunResult( + status=WorkflowNodeExecutionStatus.SUCCEEDED, + metadata={ + WorkflowNodeExecutionMetadataKey.TRIGGER_INFO: { + "provider_id": "provider-id", + "event_name": "event-name", + } + }, + ) + + assert result.metadata[WorkflowNodeExecutionMetadataKey.TRIGGER_INFO] == { + "provider_id": "provider-id", + "event_name": "event-name", + } diff --git a/api/tests/unit_tests/models/test_enums_creator_user_role.py b/api/tests/unit_tests/models/test_enums_creator_user_role.py new file mode 100644 index 0000000000..6317166fdc --- /dev/null +++ b/api/tests/unit_tests/models/test_enums_creator_user_role.py @@ -0,0 +1,19 @@ +import pytest + +from models.enums import CreatorUserRole + + +def test_creator_user_role_missing_maps_hyphen_to_enum(): + # given an alias with hyphen + value = "end-user" + + # when converting to enum (invokes StrEnum._missing_ override) + role = CreatorUserRole(value) + + # then it should map to END_USER + assert role is CreatorUserRole.END_USER + + +def test_creator_user_role_missing_raises_for_unknown(): + with pytest.raises(ValueError): + CreatorUserRole("unknown") diff --git a/api/tests/unit_tests/models/test_provider_models.py b/api/tests/unit_tests/models/test_provider_models.py index ec84a61c8e..f628e54a4d 100644 --- a/api/tests/unit_tests/models/test_provider_models.py +++ b/api/tests/unit_tests/models/test_provider_models.py @@ -19,6 +19,7 @@ from uuid import uuid4 import pytest +from models.enums import CredentialSourceType, PaymentStatus from models.provider import ( LoadBalancingModelConfig, Provider, @@ -158,7 +159,7 @@ class TestProviderModel: # Assert assert provider.tenant_id == tenant_id assert provider.provider_name == provider_name - assert provider.provider_type == "custom" + assert provider.provider_type == ProviderType.CUSTOM assert provider.is_valid is False assert provider.quota_used == 0 @@ -172,10 +173,10 @@ class TestProviderModel: provider = Provider( tenant_id=tenant_id, provider_name="anthropic", - provider_type="system", + provider_type=ProviderType.SYSTEM, is_valid=True, credential_id=credential_id, - quota_type="paid", + quota_type=ProviderQuotaType.PAID, quota_limit=10000, quota_used=500, ) @@ -183,10 +184,10 @@ class TestProviderModel: # Assert assert provider.tenant_id == tenant_id assert provider.provider_name == "anthropic" - assert provider.provider_type == "system" + assert provider.provider_type == ProviderType.SYSTEM assert provider.is_valid is True assert provider.credential_id == credential_id - assert provider.quota_type == "paid" + assert provider.quota_type == ProviderQuotaType.PAID assert provider.quota_limit == 10000 assert provider.quota_used == 500 @@ -199,7 +200,7 @@ class TestProviderModel: ) # Assert - assert provider.provider_type == "custom" + assert provider.provider_type == ProviderType.CUSTOM assert provider.is_valid is False assert provider.quota_type == "" assert provider.quota_limit is None @@ -213,7 +214,7 @@ class TestProviderModel: provider = Provider( tenant_id=tenant_id, provider_name="openai", - provider_type="custom", + provider_type=ProviderType.CUSTOM, ) # Act @@ -253,7 +254,7 @@ class TestProviderModel: provider = Provider( tenant_id=str(uuid4()), provider_name="openai", - provider_type=ProviderType.SYSTEM.value, + provider_type=ProviderType.SYSTEM, is_valid=True, ) @@ -266,13 +267,13 @@ class TestProviderModel: provider = Provider( tenant_id=str(uuid4()), provider_name="openai", - quota_type="trial", + quota_type=ProviderQuotaType.TRIAL, quota_limit=1000, quota_used=250, ) # Assert - assert provider.quota_type == "trial" + assert provider.quota_type == ProviderQuotaType.TRIAL assert provider.quota_limit == 1000 assert provider.quota_used == 250 remaining = provider.quota_limit - provider.quota_used @@ -429,13 +430,13 @@ class TestTenantPreferredModelProvider: preferred = TenantPreferredModelProvider( tenant_id=tenant_id, provider_name="openai", - preferred_provider_type="custom", + preferred_provider_type=ProviderType.CUSTOM, ) # Assert assert preferred.tenant_id == tenant_id assert preferred.provider_name == "openai" - assert preferred.preferred_provider_type == "custom" + assert preferred.preferred_provider_type == ProviderType.CUSTOM def test_tenant_preferred_provider_system_type(self): """Test tenant preferred provider with system type.""" @@ -443,11 +444,11 @@ class TestTenantPreferredModelProvider: preferred = TenantPreferredModelProvider( tenant_id=str(uuid4()), provider_name="anthropic", - preferred_provider_type="system", + preferred_provider_type=ProviderType.SYSTEM, ) # Assert - assert preferred.preferred_provider_type == "system" + assert preferred.preferred_provider_type == ProviderType.SYSTEM class TestProviderOrder: @@ -470,7 +471,7 @@ class TestProviderOrder: quantity=1, currency=None, total_amount=None, - payment_status="wait_pay", + payment_status=PaymentStatus.WAIT_PAY, paid_at=None, pay_failed_at=None, refunded_at=None, @@ -481,7 +482,7 @@ class TestProviderOrder: assert order.provider_name == "openai" assert order.account_id == account_id assert order.payment_product_id == "prod_123" - assert order.payment_status == "wait_pay" + assert order.payment_status == PaymentStatus.WAIT_PAY assert order.quantity == 1 def test_provider_order_with_payment_details(self): @@ -502,7 +503,7 @@ class TestProviderOrder: quantity=5, currency="USD", total_amount=9999, - payment_status="paid", + payment_status=PaymentStatus.PAID, paid_at=paid_time, pay_failed_at=None, refunded_at=None, @@ -514,7 +515,7 @@ class TestProviderOrder: assert order.quantity == 5 assert order.currency == "USD" assert order.total_amount == 9999 - assert order.payment_status == "paid" + assert order.payment_status == PaymentStatus.PAID assert order.paid_at == paid_time def test_provider_order_payment_statuses(self): @@ -536,23 +537,23 @@ class TestProviderOrder: } # Act & Assert - Wait pay status - wait_order = ProviderOrder(**base_params, payment_status="wait_pay") - assert wait_order.payment_status == "wait_pay" + wait_order = ProviderOrder(**base_params, payment_status=PaymentStatus.WAIT_PAY) + assert wait_order.payment_status == PaymentStatus.WAIT_PAY # Act & Assert - Paid status - paid_order = ProviderOrder(**base_params, payment_status="paid") - assert paid_order.payment_status == "paid" + paid_order = ProviderOrder(**base_params, payment_status=PaymentStatus.PAID) + assert paid_order.payment_status == PaymentStatus.PAID # Act & Assert - Failed status failed_params = {**base_params, "pay_failed_at": datetime.now(UTC)} - failed_order = ProviderOrder(**failed_params, payment_status="failed") - assert failed_order.payment_status == "failed" + failed_order = ProviderOrder(**failed_params, payment_status=PaymentStatus.FAILED) + assert failed_order.payment_status == PaymentStatus.FAILED assert failed_order.pay_failed_at is not None # Act & Assert - Refunded status refunded_params = {**base_params, "refunded_at": datetime.now(UTC)} - refunded_order = ProviderOrder(**refunded_params, payment_status="refunded") - assert refunded_order.payment_status == "refunded" + refunded_order = ProviderOrder(**refunded_params, payment_status=PaymentStatus.REFUNDED) + assert refunded_order.payment_status == PaymentStatus.REFUNDED assert refunded_order.refunded_at is not None @@ -650,13 +651,13 @@ class TestLoadBalancingModelConfig: name="Secondary API Key", encrypted_config='{"api_key": "encrypted_value"}', credential_id=credential_id, - credential_source_type="custom", + credential_source_type=CredentialSourceType.CUSTOM_MODEL, ) # Assert assert config.encrypted_config == '{"api_key": "encrypted_value"}' assert config.credential_id == credential_id - assert config.credential_source_type == "custom" + assert config.credential_source_type == CredentialSourceType.CUSTOM_MODEL def test_load_balancing_config_disabled(self): """Test disabled load balancing config.""" diff --git a/api/tests/unit_tests/services/test_website_service.py b/api/tests/unit_tests/services/test_website_service.py index e2775ce90c..e973da7d56 100644 --- a/api/tests/unit_tests/services/test_website_service.py +++ b/api/tests/unit_tests/services/test_website_service.py @@ -443,7 +443,7 @@ def test_get_firecrawl_status_adds_time_consuming_when_completed_and_cached(monk def test_get_firecrawl_status_completed_without_cache_does_not_add_time(monkeypatch: pytest.MonkeyPatch) -> None: firecrawl_instance = MagicMock() - firecrawl_instance.check_crawl_status.return_value = {"status": "completed"} + firecrawl_instance.check_crawl_status.return_value = {"status": "completed", "total": 1, "current": 1, "data": []} monkeypatch.setattr(website_service_module, "FirecrawlApp", MagicMock(return_value=firecrawl_instance)) redis_mock = MagicMock() diff --git a/api/uv.lock b/api/uv.lock index ddb70f6b54..ebfc6678fe 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1533,7 +1533,7 @@ wheels = [ [[package]] name = "dify-api" -version = "1.13.1" +version = "1.13.2" source = { virtual = "." } dependencies = [ { name = "aliyun-log-python-sdk" }, @@ -5405,11 +5405,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.8.0" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/a3/e705b0805212b663a4c27b861c8a603dba0f8b4bb281f96f8e746576a50d/pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b", size = 5307831, upload-time = "2026-03-09T13:37:40.591Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/fb/dc2e8cb006e80b0020ed20d8649106fe4274e82d8e756ad3e24ade19c0df/pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d", size = 5311551, upload-time = "2026-03-17T10:46:07.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/ec/4ccf3bb86b1afe5d7176e1c8abcdbf22b53dd682ec2eda50e1caadcf6846/pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7", size = 332177, upload-time = "2026-03-09T13:37:38.774Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/75543fa802b86e72f87e9395440fe1a89a6d149887e3e55745715c3352ac/pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f", size = 333661, upload-time = "2026-03-17T10:46:06.286Z" }, ] [[package]] @@ -7248,30 +7248,43 @@ wheels = [ [[package]] name = "ujson" -version = "5.9.0" +version = "5.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/54/6f2bdac7117e89a47de4511c9f01732a283457ab1bf856e1e51aa861619e/ujson-5.9.0.tar.gz", hash = "sha256:89cc92e73d5501b8a7f48575eeb14ad27156ad092c2e9fc7e3cf949f07e75532", size = 7154214, upload-time = "2023-12-10T22:50:34.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/3e/c35530c5ffc25b71c59ae0cd7b8f99df37313daa162ce1e2f7925f7c2877/ujson-5.12.0.tar.gz", hash = "sha256:14b2e1eb528d77bc0f4c5bd1a7ebc05e02b5b41beefb7e8567c9675b8b13bcf4", size = 7158451, upload-time = "2026-03-11T22:19:30.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/ca/ae3a6ca5b4f82ce654d6ac3dde5e59520537e20939592061ba506f4e569a/ujson-5.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b23bbb46334ce51ddb5dded60c662fbf7bb74a37b8f87221c5b0fec1ec6454b", size = 57753, upload-time = "2023-12-10T22:49:03.939Z" }, - { url = "https://files.pythonhosted.org/packages/34/5f/c27fa9a1562c96d978c39852b48063c3ca480758f3088dcfc0f3b09f8e93/ujson-5.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6974b3a7c17bbf829e6c3bfdc5823c67922e44ff169851a755eab79a3dd31ec0", size = 54092, upload-time = "2023-12-10T22:49:05.194Z" }, - { url = "https://files.pythonhosted.org/packages/19/f3/1431713de9e5992e5e33ba459b4de28f83904233958855d27da820a101f9/ujson-5.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5964ea916edfe24af1f4cc68488448fbb1ec27a3ddcddc2b236da575c12c8ae", size = 51675, upload-time = "2023-12-10T22:49:06.449Z" }, - { url = "https://files.pythonhosted.org/packages/d3/93/de6fff3ae06351f3b1c372f675fe69bc180f93d237c9e496c05802173dd6/ujson-5.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ba7cac47dd65ff88571eceeff48bf30ed5eb9c67b34b88cb22869b7aa19600d", size = 53246, upload-time = "2023-12-10T22:49:07.691Z" }, - { url = "https://files.pythonhosted.org/packages/26/73/db509fe1d7da62a15c0769c398cec66bdfc61a8bdffaf7dfa9d973e3d65c/ujson-5.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bbd91a151a8f3358c29355a491e915eb203f607267a25e6ab10531b3b157c5e", size = 58182, upload-time = "2023-12-10T22:49:08.89Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a8/6be607fa3e1fa3e1c9b53f5de5acad33b073b6cc9145803e00bcafa729a8/ujson-5.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:829a69d451a49c0de14a9fecb2a2d544a9b2c884c2b542adb243b683a6f15908", size = 584493, upload-time = "2023-12-10T22:49:11.043Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c7/33822c2f1a8175e841e2bc378ffb2c1109ce9280f14cedb1b2fa0caf3145/ujson-5.9.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:a807ae73c46ad5db161a7e883eec0fbe1bebc6a54890152ccc63072c4884823b", size = 656038, upload-time = "2023-12-10T22:49:12.651Z" }, - { url = "https://files.pythonhosted.org/packages/51/b8/5309fbb299d5fcac12bbf3db20896db5178392904abe6b992da233dc69d6/ujson-5.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8fc2aa18b13d97b3c8ccecdf1a3c405f411a6e96adeee94233058c44ff92617d", size = 597643, upload-time = "2023-12-10T22:49:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/5f/64/7b63043b95dd78feed401b9973958af62645a6d19b72b6e83d1ea5af07e0/ujson-5.9.0-cp311-cp311-win32.whl", hash = "sha256:70e06849dfeb2548be48fdd3ceb53300640bc8100c379d6e19d78045e9c26120", size = 38342, upload-time = "2023-12-10T22:49:16.854Z" }, - { url = "https://files.pythonhosted.org/packages/7a/13/a3cd1fc3a1126d30b558b6235c05e2d26eeaacba4979ee2fd2b5745c136d/ujson-5.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:7309d063cd392811acc49b5016728a5e1b46ab9907d321ebbe1c2156bc3c0b99", size = 41923, upload-time = "2023-12-10T22:49:17.983Z" }, - { url = "https://files.pythonhosted.org/packages/16/7e/c37fca6cd924931fa62d615cdbf5921f34481085705271696eff38b38867/ujson-5.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:20509a8c9f775b3a511e308bbe0b72897ba6b800767a7c90c5cca59d20d7c42c", size = 57834, upload-time = "2023-12-10T22:49:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/fb/44/2753e902ee19bf6ccaf0bda02f1f0037f92a9769a5d31319905e3de645b4/ujson-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b28407cfe315bd1b34f1ebe65d3bd735d6b36d409b334100be8cdffae2177b2f", size = 54119, upload-time = "2023-12-10T22:49:21.039Z" }, - { url = "https://files.pythonhosted.org/packages/d2/06/2317433e394450bc44afe32b6c39d5a51014da4c6f6cfc2ae7bf7b4a2922/ujson-5.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d302bd17989b6bd90d49bade66943c78f9e3670407dbc53ebcf61271cadc399", size = 51658, upload-time = "2023-12-10T22:49:22.494Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3a/2acf0da085d96953580b46941504aa3c91a1dd38701b9e9bfa43e2803467/ujson-5.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f21315f51e0db8ee245e33a649dd2d9dce0594522de6f278d62f15f998e050e", size = 53370, upload-time = "2023-12-10T22:49:24.045Z" }, - { url = "https://files.pythonhosted.org/packages/03/32/737e6c4b1841720f88ae88ec91f582dc21174bd40742739e1fa16a0c9ffa/ujson-5.9.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5635b78b636a54a86fdbf6f027e461aa6c6b948363bdf8d4fbb56a42b7388320", size = 58278, upload-time = "2023-12-10T22:49:25.261Z" }, - { url = "https://files.pythonhosted.org/packages/8a/dc/3fda97f1ad070ccf2af597fb67dde358bc698ffecebe3bc77991d60e4fe5/ujson-5.9.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:82b5a56609f1235d72835ee109163c7041b30920d70fe7dac9176c64df87c164", size = 584418, upload-time = "2023-12-10T22:49:27.573Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/e4083d774fcd8ff3089c0ff19c424abe33f23e72c6578a8172bf65131992/ujson-5.9.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5ca35f484622fd208f55041b042d9d94f3b2c9c5add4e9af5ee9946d2d30db01", size = 656126, upload-time = "2023-12-10T22:49:29.509Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/8c6d5f6506ca9fcedd5a211e30a7d5ee053dc05caf23dae650e1f897effb/ujson-5.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:829b824953ebad76d46e4ae709e940bb229e8999e40881338b3cc94c771b876c", size = 597795, upload-time = "2023-12-10T22:49:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/34/5a/a231f0cd305a34cf2d16930304132db3a7a8c3997b367dd38fc8f8dfae36/ujson-5.9.0-cp312-cp312-win32.whl", hash = "sha256:25fa46e4ff0a2deecbcf7100af3a5d70090b461906f2299506485ff31d9ec437", size = 38495, upload-time = "2023-12-10T22:49:33.2Z" }, - { url = "https://files.pythonhosted.org/packages/30/b7/18b841b44760ed298acdb150608dccdc045c41655e0bae4441f29bcab872/ujson-5.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:60718f1720a61560618eff3b56fd517d107518d3c0160ca7a5a66ac949c6cf1c", size = 42088, upload-time = "2023-12-10T22:49:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/10/22/fd22e2f6766bae934d3050517ca47d463016bd8688508d1ecc1baa18a7ad/ujson-5.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:58a11cb49482f1a095a2bd9a1d81dd7c8fb5d2357f959ece85db4e46a825fd00", size = 56139, upload-time = "2026-03-11T22:18:04.591Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fd/6839adff4fc0164cbcecafa2857ba08a6eaeedd7e098d6713cb899a91383/ujson-5.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9b3cf13facf6f77c283af0e1713e5e8c47a0fe295af81326cb3cb4380212e797", size = 53836, upload-time = "2026-03-11T22:18:05.662Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b0/0c19faac62d68ceeffa83a08dc3d71b8462cf5064d0e7e0b15ba19898dad/ujson-5.12.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb94245a715b4d6e24689de12772b85329a1f9946cbf6187923a64ecdea39e65", size = 57851, upload-time = "2026-03-11T22:18:06.744Z" }, + { url = "https://files.pythonhosted.org/packages/04/f6/e7fd283788de73b86e99e08256726bb385923249c21dcd306e59d532a1a1/ujson-5.12.0-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:0fe6b8b8968e11dd9b2348bd508f0f57cf49ab3512064b36bc4117328218718e", size = 59906, upload-time = "2026-03-11T22:18:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3a/b100735a2b43ee6e8fe4c883768e362f53576f964d4ea841991060aeaf35/ujson-5.12.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89e302abd3749f6d6699691747969a5d85f7c73081d5ed7e2624c7bd9721a2ab", size = 57409, upload-time = "2026-03-11T22:18:08.79Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/f97cc20c99ca304662191b883ae13ae02912ca7244710016ba0cb8a5be34/ujson-5.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0727363b05ab05ee737a28f6200dc4078bce6b0508e10bd8aab507995a15df61", size = 1037339, upload-time = "2026-03-11T22:18:10.424Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/53ddeda0ffe1420db2f9999897b3cbb920fbcff1849d1f22b196d0f34785/ujson-5.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b62cb9a7501e1f5c9ffe190485501349c33e8862dde4377df774e40b8166871f", size = 1196625, upload-time = "2026-03-11T22:18:11.82Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/4c64a6bef522e9baf195dd5be151bc815cd4896c50c6e2489599edcda85f/ujson-5.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6ec5bf6bc361f2f0f9644907a36ce527715b488988a8df534120e5c34eeda94", size = 1089669, upload-time = "2026-03-11T22:18:13.343Z" }, + { url = "https://files.pythonhosted.org/packages/18/11/8ccb109f5777ec0d9fb826695a9e2ac36ae94c1949fc8b1e4d23a5bd067a/ujson-5.12.0-cp311-cp311-win32.whl", hash = "sha256:006428d3813b87477d72d306c40c09f898a41b968e57b15a7d88454ecc42a3fb", size = 39648, upload-time = "2026-03-11T22:18:14.785Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/87fc4c27b20d5125cff7ce52d17ea7698b22b74426da0df238e3efcb0cf2/ujson-5.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:40aa43a7a3a8d2f05e79900858053d697a88a605e3887be178b43acbcd781161", size = 43876, upload-time = "2026-03-11T22:18:15.768Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/324f0548a8c8c48e3e222eaed15fb6d48c796593002b206b4a28a89e445f/ujson-5.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:561f89cc82deeae82e37d4a4764184926fb432f740a9691563a391b13f7339a4", size = 38553, upload-time = "2026-03-11T22:18:17.251Z" }, + { url = "https://files.pythonhosted.org/packages/84/f6/ac763d2108d28f3a40bb3ae7d2fafab52ca31b36c2908a4ad02cd3ceba2a/ujson-5.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:09b4beff9cc91d445d5818632907b85fb06943b61cb346919ce202668bf6794a", size = 56326, upload-time = "2026-03-11T22:18:18.467Z" }, + { url = "https://files.pythonhosted.org/packages/25/46/d0b3af64dcdc549f9996521c8be6d860ac843a18a190ffc8affeb7259687/ujson-5.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ca0c7ce828bb76ab78b3991904b477c2fd0f711d7815c252d1ef28ff9450b052", size = 53910, upload-time = "2026-03-11T22:18:19.502Z" }, + { url = "https://files.pythonhosted.org/packages/9a/10/853c723bcabc3e9825a079019055fc99e71b85c6bae600607a2b9d31d18d/ujson-5.12.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d79c6635ccffcbfc1d5c045874ba36b594589be81d50d43472570bb8de9c57", size = 57754, upload-time = "2026-03-11T22:18:20.874Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c6/6e024830d988f521f144ead641981c1f7a82c17ad1927c22de3242565f5c/ujson-5.12.0-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:7e07f6f644d2c44d53b7a320a084eef98063651912c1b9449b5f45fcbdc6ccd2", size = 59936, upload-time = "2026-03-11T22:18:21.924Z" }, + { url = "https://files.pythonhosted.org/packages/34/c9/c5f236af5abe06b720b40b88819d00d10182d2247b1664e487b3ed9229cf/ujson-5.12.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:085b6ce182cdd6657481c7c4003a417e0655c4f6e58b76f26ee18f0ae21db827", size = 57463, upload-time = "2026-03-11T22:18:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/41342d9ef68e793a87d84e4531a150c2b682f3bcedfe59a7a5e3f73e9213/ujson-5.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:16b4fe9c97dc605f5e1887a9e1224287291e35c56cbc379f8aa44b6b7bcfe2bb", size = 1037239, upload-time = "2026-03-11T22:18:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/d4/81/dc2b7617d5812670d4ff4a42f6dd77926430ee52df0dedb2aec7990b2034/ujson-5.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0d2e8db5ade3736a163906154ca686203acc7d1d30736cbf577c730d13653d84", size = 1196713, upload-time = "2026-03-11T22:18:25.391Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9c/80acff0504f92459ed69e80a176286e32ca0147ac6a8252cd0659aad3227/ujson-5.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93bc91fdadcf046da37a214eaa714574e7e9b1913568e93bb09527b2ceb7f759", size = 1089742, upload-time = "2026-03-11T22:18:26.738Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f0/123ffaac17e45ef2b915e3e3303f8f4ea78bb8d42afad828844e08622b1e/ujson-5.12.0-cp312-cp312-win32.whl", hash = "sha256:2a248750abce1c76fbd11b2e1d88b95401e72819295c3b851ec73399d6849b3d", size = 39773, upload-time = "2026-03-11T22:18:28.244Z" }, + { url = "https://files.pythonhosted.org/packages/b5/20/f3bd2b069c242c2b22a69e033bfe224d1d15d3649e6cd7cc7085bb1412ff/ujson-5.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:1b5c6ceb65fecd28a1d20d1eba9dbfa992612b86594e4b6d47bb580d2dd6bcb3", size = 44040, upload-time = "2026-03-11T22:18:29.236Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/01b5a0bcded14cd2522b218f2edc3533b0fcbccdea01f3e14a2b699071aa/ujson-5.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:9a5fcbe7b949f2e95c47ea8a80b410fcdf2da61c98553b45a4ee875580418b68", size = 38526, upload-time = "2026-03-11T22:18:30.551Z" }, + { url = "https://files.pythonhosted.org/packages/95/3c/5ee154d505d1aad2debc4ba38b1a60ae1949b26cdb5fa070e85e320d6b64/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:bf85a00ac3b56a1e7a19c5be7b02b5180a0895ac4d3c234d717a55e86960691c", size = 54494, upload-time = "2026-03-11T22:19:13.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b3/9496ec399ec921e434a93b340bd5052999030b7ac364be4cbe5365ac6b20/ujson-5.12.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:64df53eef4ac857eb5816a56e2885ccf0d7dff6333c94065c93b39c51063e01d", size = 57999, upload-time = "2026-03-11T22:19:14.385Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/e9ae98133336e7c0d50b43626c3f2327937cecfa354d844e02ac17379ed1/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c0aed6a4439994c9666fb8a5b6c4eac94d4ef6ddc95f9b806a599ef83547e3b", size = 54518, upload-time = "2026-03-11T22:19:15.4Z" }, + { url = "https://files.pythonhosted.org/packages/58/10/978d89dded6bb1558cd46ba78f4351198bd2346db8a8ee1a94119022ce40/ujson-5.12.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efae5df7a8cc8bdb1037b0f786b044ce281081441df5418c3a0f0e1f86fe7bb3", size = 55736, upload-time = "2026-03-11T22:19:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/1df8e6217c92e57a1266bf5be750b1dddc126ee96e53fe959d5693503bc6/ujson-5.12.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:8712b61eb1b74a4478cfd1c54f576056199e9f093659334aeb5c4a6b385338e5", size = 44615, upload-time = "2026-03-11T22:19:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/f4a957dddb99bd68c8be91928c0b6fefa7aa8aafc92c93f5d1e8b32f6702/ujson-5.12.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:871c0e5102e47995b0e37e8df7819a894a6c3da0d097545cd1f9f1f7d7079927", size = 52145, upload-time = "2026-03-11T22:19:18.566Z" }, + { url = "https://files.pythonhosted.org/packages/55/6e/50b5cf612de1ca06c7effdc5a5d7e815774dee85a5858f1882c425553b82/ujson-5.12.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:56ba3f7abbd6b0bb282a544dc38406d1a188d8bb9164f49fdb9c2fee62cb29da", size = 49577, upload-time = "2026-03-11T22:19:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/b6713fa9897774502cd4c2d6955bb4933349f7d84c3aa805531c382a4209/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c5a52987a990eb1bae55f9000994f1afdb0326c154fb089992f839ab3c30688", size = 50807, upload-time = "2026-03-11T22:19:20.778Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/c0e0f7901180ef80d16f3a4bccb5dc8b01515a717336a62928963a07b80b/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:adf28d13a33f9d750fe7a78fb481cac298fa257d8863d8727b2ea4455ea41235", size = 56972, upload-time = "2026-03-11T22:19:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/02/a9/05d91b4295ea7239151eb08cf240e5a2ba969012fda50bc27bcb1ea9cd71/ujson-5.12.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51acc750ec7a2df786cdc868fb16fa04abd6269a01d58cf59bafc57978773d8e", size = 52045, upload-time = "2026-03-11T22:19:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/92047d32bf6f2d9db64605fc32e8eb0e0dd68b671eaafc12a464f69c4af4/ujson-5.12.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ab9056d94e5db513d9313b34394f3a3b83e6301a581c28ad67773434f3faccab", size = 44053, upload-time = "2026-03-11T22:19:23.918Z" }, ] [[package]] diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..54ac2a4b36 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,16 @@ +coverage: + status: + project: + default: + target: auto + +flags: + web: + paths: + - "web/" + carryforward: true + + api: + paths: + - "api/" + carryforward: true diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml index 939f23136a..04bd2858ff 100644 --- a/docker/docker-compose-template.yaml +++ b/docker/docker-compose-template.yaml @@ -21,7 +21,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -63,7 +63,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -102,7 +102,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -132,7 +132,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.1 + image: langgenius/dify-web:1.13.2 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index b6b6f299cf..bf72a0f623 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -728,7 +728,7 @@ services: # API service api: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -770,7 +770,7 @@ services: # worker service # The Celery worker for processing all queues (dataset, workflow, mail, etc.) worker: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -809,7 +809,7 @@ services: # worker_beat service # Celery beat for scheduling periodic tasks. worker_beat: - image: langgenius/dify-api:1.13.1 + image: langgenius/dify-api:1.13.2 restart: always environment: # Use the shared environment variables. @@ -839,7 +839,7 @@ services: # Frontend web application. web: - image: langgenius/dify-web:1.13.1 + image: langgenius/dify-web:1.13.2 restart: always environment: CONSOLE_API_URL: ${CONSOLE_API_URL:-} diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index c3e8410955..c5766878a1 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -29,7 +29,7 @@ const mockOnPlanInfoChanged = vi.fn() const mockDeleteAppMutation = vi.fn().mockResolvedValue(undefined) let mockDeleteMutationPending = false -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), @@ -57,7 +57,7 @@ vi.mock('@headlessui/react', async () => { } }) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<{ default: React.ComponentType }>) => { let Component: React.ComponentType> | null = null loader().then((mod) => { diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index 079f667dbc..1be7e56086 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -38,7 +38,7 @@ let mockShowTagManagementModal = false const mockRouterPush = vi.fn() const mockRouterReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace, @@ -46,7 +46,7 @@ vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams(), })) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (_loader: () => Promise<{ default: React.ComponentType }>) => { const LazyComponent = (props: Record) => { return
diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 4ac9824ddd..bc1f7a3a06 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -35,7 +35,7 @@ const mockRouterPush = vi.fn() const mockRouterReplace = vi.fn() const mockOnPlanInfoChanged = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, replace: mockRouterReplace, @@ -117,7 +117,7 @@ vi.mock('ahooks', async () => { }) // Mock dynamically loaded modals with test stubs -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<{ default: React.ComponentType }>) => { let Component: React.ComponentType> | null = null loader().then((mod) => { diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 4891760df4..64d358cbe6 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -64,7 +64,7 @@ vi.mock('@/service/use-education', () => ({ // ─── Navigation mocks ─────────────────────────────────────────────────────── const mockRouterPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush }), usePathname: () => '/billing', useSearchParams: () => new URLSearchParams(), diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index e01d9250fd..84653cd68c 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -11,6 +11,7 @@ import type { BasicPlan } from '@/app/components/billing/type' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { toast, ToastHost } from '@/app/components/base/ui/toast' import { ALL_PLANS } from '@/app/components/billing/config' import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher' import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item' @@ -21,7 +22,6 @@ let mockAppCtx: Record = {} const mockFetchSubscriptionUrls = vi.fn() const mockInvoices = vi.fn() const mockOpenAsyncWindow = vi.fn() -const mockToastNotify = vi.fn() // ─── Context mocks ─────────────────────────────────────────────────────────── vi.mock('@/context/app-context', () => ({ @@ -49,12 +49,8 @@ vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => mockOpenAsyncWindow, })) -vi.mock('@/app/components/base/toast', () => ({ - default: { notify: (args: unknown) => mockToastNotify(args) }, -})) - // ─── Navigation mocks ─────────────────────────────────────────────────────── -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/billing', useSearchParams: () => new URLSearchParams(), @@ -82,12 +78,15 @@ const renderCloudPlanItem = ({ canPay = true, }: RenderCloudPlanItemOptions = {}) => { return render( - , + <> + + + , ) } @@ -96,6 +95,7 @@ describe('Cloud Plan Payment Flow', () => { beforeEach(() => { vi.clearAllMocks() cleanup() + toast.close() setupAppContext() mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' }) mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' }) @@ -283,11 +283,7 @@ describe('Cloud Plan Payment Flow', () => { await user.click(button) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - }), - ) + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() }) // Should not proceed with payment expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled() diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 8c35cd9a8c..707f1d690a 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -63,7 +63,7 @@ vi.mock('@/service/use-billing', () => ({ })) // ─── Navigation mocks ─────────────────────────────────────────────────────── -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush }), usePathname: () => '/billing', useSearchParams: () => new URLSearchParams(), diff --git a/web/__tests__/billing/partner-stack-flow.test.tsx b/web/__tests__/billing/partner-stack-flow.test.tsx index 4f265478cd..fe642ac70b 100644 --- a/web/__tests__/billing/partner-stack-flow.test.tsx +++ b/web/__tests__/billing/partner-stack-flow.test.tsx @@ -18,7 +18,7 @@ let mockSearchParams = new URLSearchParams() const mockMutateAsync = vi.fn() // ─── Module mocks ──────────────────────────────────────────────────────────── -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSearchParams: () => mockSearchParams, useRouter: () => ({ push: vi.fn() }), usePathname: () => '/', diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index 7326ee3559..2ec7298618 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -51,7 +51,7 @@ vi.mock('@/hooks/use-async-window-open', () => ({ })) // ─── Navigation mocks ─────────────────────────────────────────────────────── -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/billing', useSearchParams: () => new URLSearchParams(), diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx index 810d36da8a..0802b760e1 100644 --- a/web/__tests__/billing/self-hosted-plan-flow.test.tsx +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -10,12 +10,12 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' +import { toast, ToastHost } from '@/app/components/base/ui/toast' import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config' import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item' import { SelfHostedPlan } from '@/app/components/billing/type' let mockAppCtx: Record = {} -const mockToastNotify = vi.fn() const originalLocation = window.location let assignedHref = '' @@ -40,10 +40,6 @@ vi.mock('@/app/components/base/icons/src/public/billing', () => ({ AwsMarketplaceDark: () => , })) -vi.mock('@/app/components/base/toast', () => ({ - default: { notify: (args: unknown) => mockToastNotify(args) }, -})) - vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({ default: ({ plan }: { plan: string }) => (
Features
@@ -57,10 +53,20 @@ const setupAppContext = (overrides: Record = {}) => { } } +const renderSelfHostedPlanItem = (plan: SelfHostedPlan) => { + return render( + <> + + + , + ) +} + describe('Self-Hosted Plan Flow', () => { beforeEach(() => { vi.clearAllMocks() cleanup() + toast.close() setupAppContext() // Mock window.location with minimal getter/setter (Location props are non-enumerable) @@ -85,14 +91,14 @@ describe('Self-Hosted Plan Flow', () => { // ─── 1. Plan Rendering ────────────────────────────────────────────────── describe('Plan rendering', () => { it('should render community plan with name and description', () => { - render() + renderSelfHostedPlanItem(SelfHostedPlan.community) expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument() expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument() }) it('should render premium plan with cloud provider icons', () => { - render() + renderSelfHostedPlanItem(SelfHostedPlan.premium) expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument() expect(screen.getByTestId('icon-azure')).toBeInTheDocument() @@ -100,39 +106,39 @@ describe('Self-Hosted Plan Flow', () => { }) it('should render enterprise plan without cloud provider icons', () => { - render() + renderSelfHostedPlanItem(SelfHostedPlan.enterprise) expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument() expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument() }) it('should not show price tip for community (free) plan', () => { - render() + renderSelfHostedPlanItem(SelfHostedPlan.community) expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument() }) it('should show price tip for premium plan', () => { - render() + renderSelfHostedPlanItem(SelfHostedPlan.premium) expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument() }) it('should render features list for each plan', () => { - const { unmount: unmount1 } = render() + const { unmount: unmount1 } = renderSelfHostedPlanItem(SelfHostedPlan.community) expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument() unmount1() - const { unmount: unmount2 } = render() + const { unmount: unmount2 } = renderSelfHostedPlanItem(SelfHostedPlan.premium) expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument() unmount2() - render() + renderSelfHostedPlanItem(SelfHostedPlan.enterprise) expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument() }) it('should show AWS marketplace icon for premium plan button', () => { - render() + renderSelfHostedPlanItem(SelfHostedPlan.premium) expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument() }) @@ -142,7 +148,7 @@ describe('Self-Hosted Plan Flow', () => { describe('Navigation flow', () => { it('should redirect to GitHub when clicking community plan button', async () => { const user = userEvent.setup() - render() + renderSelfHostedPlanItem(SelfHostedPlan.community) const button = screen.getByRole('button') await user.click(button) @@ -152,7 +158,7 @@ describe('Self-Hosted Plan Flow', () => { it('should redirect to AWS Marketplace when clicking premium plan button', async () => { const user = userEvent.setup() - render() + renderSelfHostedPlanItem(SelfHostedPlan.premium) const button = screen.getByRole('button') await user.click(button) @@ -162,7 +168,7 @@ describe('Self-Hosted Plan Flow', () => { it('should redirect to Typeform when clicking enterprise plan button', async () => { const user = userEvent.setup() - render() + renderSelfHostedPlanItem(SelfHostedPlan.enterprise) const button = screen.getByRole('button') await user.click(button) @@ -176,15 +182,13 @@ describe('Self-Hosted Plan Flow', () => { it('should show error toast when non-manager clicks community button', async () => { setupAppContext({ isCurrentWorkspaceManager: false }) const user = userEvent.setup() - render() + renderSelfHostedPlanItem(SelfHostedPlan.community) const button = screen.getByRole('button') await user.click(button) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() }) // Should NOT redirect expect(assignedHref).toBe('') @@ -193,15 +197,13 @@ describe('Self-Hosted Plan Flow', () => { it('should show error toast when non-manager clicks premium button', async () => { setupAppContext({ isCurrentWorkspaceManager: false }) const user = userEvent.setup() - render() + renderSelfHostedPlanItem(SelfHostedPlan.premium) const button = screen.getByRole('button') await user.click(button) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() }) expect(assignedHref).toBe('') }) @@ -209,15 +211,13 @@ describe('Self-Hosted Plan Flow', () => { it('should show error toast when non-manager clicks enterprise button', async () => { setupAppContext({ isCurrentWorkspaceManager: false }) const user = userEvent.setup() - render() + renderSelfHostedPlanItem(SelfHostedPlan.enterprise) const button = screen.getByRole('button') await user.click(button) await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), - ) + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() }) expect(assignedHref).toBe('') }) diff --git a/web/__tests__/check-components-diff-coverage.test.ts b/web/__tests__/check-components-diff-coverage.test.ts deleted file mode 100644 index 62e5ff5ed5..0000000000 --- a/web/__tests__/check-components-diff-coverage.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { - buildGitDiffRevisionArgs, - getChangedBranchCoverage, - getChangedStatementCoverage, - getIgnoredChangedLinesFromSource, - normalizeToRepoRelative, - parseChangedLineMap, -} from '../scripts/check-components-diff-coverage-lib.mjs' - -describe('check-components-diff-coverage helpers', () => { - it('should build exact and merge-base git diff revision args', () => { - expect(buildGitDiffRevisionArgs('base-sha', 'head-sha', 'exact')).toEqual(['base-sha', 'head-sha']) - expect(buildGitDiffRevisionArgs('base-sha', 'head-sha')).toEqual(['base-sha...head-sha']) - }) - - it('should parse changed line maps from unified diffs', () => { - const diff = [ - 'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts', - '+++ b/web/app/components/share/a.ts', - '@@ -10,0 +11,2 @@', - '+const a = 1', - '+const b = 2', - 'diff --git a/web/app/components/base/b.ts b/web/app/components/base/b.ts', - '+++ b/web/app/components/base/b.ts', - '@@ -20 +21 @@', - '+const c = 3', - 'diff --git a/web/README.md b/web/README.md', - '+++ b/web/README.md', - '@@ -1 +1 @@', - '+ignore me', - ].join('\n') - - const lineMap = parseChangedLineMap(diff, (filePath: string) => filePath.startsWith('web/app/components/')) - - expect([...lineMap.entries()]).toEqual([ - ['web/app/components/share/a.ts', new Set([11, 12])], - ['web/app/components/base/b.ts', new Set([21])], - ]) - }) - - it('should normalize coverage and absolute paths to repo-relative paths', () => { - const repoRoot = '/repo' - const webRoot = '/repo/web' - - expect(normalizeToRepoRelative('web/app/components/share/a.ts', { - appComponentsCoveragePrefix: 'app/components/', - appComponentsPrefix: 'web/app/components/', - repoRoot, - sharedTestPrefix: 'web/__tests__/', - webRoot, - })).toBe('web/app/components/share/a.ts') - - expect(normalizeToRepoRelative('app/components/share/a.ts', { - appComponentsCoveragePrefix: 'app/components/', - appComponentsPrefix: 'web/app/components/', - repoRoot, - sharedTestPrefix: 'web/__tests__/', - webRoot, - })).toBe('web/app/components/share/a.ts') - - expect(normalizeToRepoRelative('/repo/web/app/components/share/a.ts', { - appComponentsCoveragePrefix: 'app/components/', - appComponentsPrefix: 'web/app/components/', - repoRoot, - sharedTestPrefix: 'web/__tests__/', - webRoot, - })).toBe('web/app/components/share/a.ts') - }) - - it('should calculate changed statement coverage from changed lines', () => { - const entry = { - s: { 0: 1, 1: 0 }, - statementMap: { - 0: { start: { line: 10 }, end: { line: 10 } }, - 1: { start: { line: 12 }, end: { line: 13 } }, - }, - } - - const coverage = getChangedStatementCoverage(entry, new Set([10, 12])) - - expect(coverage).toEqual({ - covered: 1, - total: 2, - uncoveredLines: [12], - }) - }) - - it('should report the first changed line inside a multi-line uncovered statement', () => { - const entry = { - s: { 0: 0 }, - statementMap: { - 0: { start: { line: 10 }, end: { line: 14 } }, - }, - } - - const coverage = getChangedStatementCoverage(entry, new Set([13, 14])) - - expect(coverage).toEqual({ - covered: 0, - total: 1, - uncoveredLines: [13], - }) - }) - - it('should fail changed lines when a source file has no coverage entry', () => { - const coverage = getChangedStatementCoverage(undefined, new Set([42, 43])) - - expect(coverage).toEqual({ - covered: 0, - total: 2, - uncoveredLines: [42, 43], - }) - }) - - it('should calculate changed branch coverage using changed branch definitions', () => { - const entry = { - b: { - 0: [1, 0], - }, - branchMap: { - 0: { - line: 20, - loc: { start: { line: 20 }, end: { line: 20 } }, - locations: [ - { start: { line: 20 }, end: { line: 20 } }, - { start: { line: 21 }, end: { line: 21 } }, - ], - type: 'if', - }, - }, - } - - const coverage = getChangedBranchCoverage(entry, new Set([20])) - - expect(coverage).toEqual({ - covered: 1, - total: 2, - uncoveredBranches: [ - { armIndex: 1, line: 21 }, - ], - }) - }) - - it('should report the first changed line inside a multi-line uncovered branch arm', () => { - const entry = { - b: { - 0: [0, 0], - }, - branchMap: { - 0: { - line: 30, - loc: { start: { line: 30 }, end: { line: 35 } }, - locations: [ - { start: { line: 31 }, end: { line: 34 } }, - { start: { line: 35 }, end: { line: 38 } }, - ], - type: 'if', - }, - }, - } - - const coverage = getChangedBranchCoverage(entry, new Set([33])) - - expect(coverage).toEqual({ - covered: 0, - total: 1, - uncoveredBranches: [ - { armIndex: 0, line: 33 }, - ], - }) - }) - - it('should require all branch arms when the branch condition changes', () => { - const entry = { - b: { - 0: [0, 0], - }, - branchMap: { - 0: { - line: 30, - loc: { start: { line: 30 }, end: { line: 35 } }, - locations: [ - { start: { line: 31 }, end: { line: 34 } }, - { start: { line: 35 }, end: { line: 38 } }, - ], - type: 'if', - }, - }, - } - - const coverage = getChangedBranchCoverage(entry, new Set([30])) - - expect(coverage).toEqual({ - covered: 0, - total: 2, - uncoveredBranches: [ - { armIndex: 0, line: 31 }, - { armIndex: 1, line: 35 }, - ], - }) - }) - - it('should ignore changed lines with valid pragma reasons and report invalid pragmas', () => { - const sourceCode = [ - 'const a = 1', - 'const b = 2 // diff-coverage-ignore-line: defensive fallback', - 'const c = 3 // diff-coverage-ignore-line:', - 'const d = 4 // diff-coverage-ignore-line: not changed', - ].join('\n') - - const result = getIgnoredChangedLinesFromSource(sourceCode, new Set([2, 3])) - - expect([...result.effectiveChangedLines]).toEqual([3]) - expect([...result.ignoredLines.entries()]).toEqual([ - [2, 'defensive fallback'], - ]) - expect(result.invalidPragmas).toEqual([ - { line: 3, reason: 'missing ignore reason' }, - ]) - }) -}) diff --git a/web/__tests__/component-coverage-filters.test.ts b/web/__tests__/component-coverage-filters.test.ts deleted file mode 100644 index cacc1e2142..0000000000 --- a/web/__tests__/component-coverage-filters.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { - collectComponentCoverageExcludedFiles, - COMPONENT_COVERAGE_EXCLUDE_LABEL, - getComponentCoverageExclusionReasons, -} from '../scripts/component-coverage-filters.mjs' - -describe('component coverage filters', () => { - describe('getComponentCoverageExclusionReasons', () => { - it('should exclude type-only files by basename', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/share/text-generation/types.ts', - 'export type ShareMode = "run-once" | "run-batch"', - ), - ).toContain('type-only') - }) - - it('should exclude pure barrel files', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/base/amplitude/index.ts', - [ - 'export { default } from "./AmplitudeProvider"', - 'export { resetUser, trackEvent } from "./utils"', - ].join('\n'), - ), - ).toContain('pure-barrel') - }) - - it('should exclude generated files from marker comments', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/base/icons/src/vender/workflow/Answer.tsx', - [ - '// GENERATE BY script', - '// DON NOT EDIT IT MANUALLY', - 'export default function Icon() {', - ' return null', - '}', - ].join('\n'), - ), - ).toContain('generated') - }) - - it('should exclude pure static files with exported constants only', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/workflow/note-node/constants.ts', - [ - 'import { NoteTheme } from "./types"', - 'export const CUSTOM_NOTE_NODE = "custom-note"', - 'export const THEME_MAP = {', - ' [NoteTheme.blue]: { title: "bg-blue-100" },', - '}', - ].join('\n'), - ), - ).toContain('pure-static') - }) - - it('should keep runtime logic files tracked', () => { - expect( - getComponentCoverageExclusionReasons( - 'web/app/components/workflow/nodes/trigger-schedule/default.ts', - [ - 'const validate = (value: string) => value.trim()', - 'export const nodeDefault = {', - ' value: validate("x"),', - '}', - ].join('\n'), - ), - ).toEqual([]) - }) - }) - - describe('collectComponentCoverageExcludedFiles', () => { - const tempDirs: string[] = [] - - afterEach(() => { - for (const dir of tempDirs) - fs.rmSync(dir, { recursive: true, force: true }) - tempDirs.length = 0 - }) - - it('should collect excluded files for coverage config and keep runtime files out', () => { - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'component-coverage-filters-')) - tempDirs.push(rootDir) - - fs.mkdirSync(path.join(rootDir, 'barrel'), { recursive: true }) - fs.mkdirSync(path.join(rootDir, 'icons'), { recursive: true }) - fs.mkdirSync(path.join(rootDir, 'static'), { recursive: true }) - fs.mkdirSync(path.join(rootDir, 'runtime'), { recursive: true }) - - fs.writeFileSync(path.join(rootDir, 'barrel', 'index.ts'), 'export { default } from "./Button"\n') - fs.writeFileSync(path.join(rootDir, 'icons', 'generated-icon.tsx'), '// @generated\nexport default function Icon() { return null }\n') - fs.writeFileSync(path.join(rootDir, 'static', 'constants.ts'), 'export const COLORS = { primary: "#fff" }\n') - fs.writeFileSync(path.join(rootDir, 'runtime', 'config.ts'), 'export const config = makeConfig()\n') - fs.writeFileSync(path.join(rootDir, 'runtime', 'types.ts'), 'export type Config = { value: string }\n') - - expect(collectComponentCoverageExcludedFiles(rootDir, { pathPrefix: 'app/components' })).toEqual([ - 'app/components/barrel/index.ts', - 'app/components/icons/generated-icon.tsx', - 'app/components/runtime/types.ts', - 'app/components/static/constants.ts', - ]) - }) - }) - - it('should describe the excluded coverage categories', () => { - expect(COMPONENT_COVERAGE_EXCLUDE_LABEL).toBe('type-only files, pure barrel files, generated files, pure static files') - }) -}) diff --git a/web/__tests__/components-coverage-common.test.ts b/web/__tests__/components-coverage-common.test.ts deleted file mode 100644 index ab189ed854..0000000000 --- a/web/__tests__/components-coverage-common.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - getCoverageStats, - isRelevantTestFile, - isTrackedComponentSourceFile, - loadTrackedCoverageEntries, -} from '../scripts/components-coverage-common.mjs' - -describe('components coverage common helpers', () => { - it('should identify tracked component source files and relevant tests', () => { - const excludedComponentCoverageFiles = new Set([ - 'web/app/components/share/types.ts', - ]) - - expect(isTrackedComponentSourceFile('web/app/components/share/index.tsx', excludedComponentCoverageFiles)).toBe(true) - expect(isTrackedComponentSourceFile('web/app/components/share/types.ts', excludedComponentCoverageFiles)).toBe(false) - expect(isTrackedComponentSourceFile('web/app/components/provider/index.tsx', excludedComponentCoverageFiles)).toBe(false) - - expect(isRelevantTestFile('web/__tests__/share/text-generation-run-once-flow.test.tsx')).toBe(true) - expect(isRelevantTestFile('web/app/components/share/__tests__/index.spec.tsx')).toBe(true) - expect(isRelevantTestFile('web/utils/format.spec.ts')).toBe(false) - }) - - it('should load only tracked coverage entries from mixed coverage paths', () => { - const context = { - excludedComponentCoverageFiles: new Set([ - 'web/app/components/share/types.ts', - ]), - repoRoot: '/repo', - webRoot: '/repo/web', - } - const coverage = { - '/repo/web/app/components/provider/index.tsx': { - path: '/repo/web/app/components/provider/index.tsx', - statementMap: { 0: { start: { line: 1 }, end: { line: 1 } } }, - s: { 0: 1 }, - }, - 'app/components/share/index.tsx': { - path: 'app/components/share/index.tsx', - statementMap: { 0: { start: { line: 2 }, end: { line: 2 } } }, - s: { 0: 1 }, - }, - 'app/components/share/types.ts': { - path: 'app/components/share/types.ts', - statementMap: { 0: { start: { line: 3 }, end: { line: 3 } } }, - s: { 0: 1 }, - }, - } - - expect([...loadTrackedCoverageEntries(coverage, context).keys()]).toEqual([ - 'web/app/components/share/index.tsx', - ]) - }) - - it('should calculate coverage stats using statement-derived line hits', () => { - const entry = { - b: { 0: [1, 0] }, - f: { 0: 1, 1: 0 }, - s: { 0: 1, 1: 0 }, - statementMap: { - 0: { start: { line: 10 }, end: { line: 10 } }, - 1: { start: { line: 12 }, end: { line: 13 } }, - }, - } - - expect(getCoverageStats(entry)).toEqual({ - branches: { covered: 1, total: 2 }, - functions: { covered: 1, total: 2 }, - lines: { covered: 1, total: 2 }, - statements: { covered: 1, total: 2 }, - }) - }) -}) diff --git a/web/__tests__/datasets/document-management.test.tsx b/web/__tests__/datasets/document-management.test.tsx index 8aedd4fc63..f9d80520ed 100644 --- a/web/__tests__/datasets/document-management.test.tsx +++ b/web/__tests__/datasets/document-management.test.tsx @@ -13,7 +13,7 @@ import { DataSourceType } from '@/models/datasets' import { renderHookWithNuqs } from '@/test/nuqs-testing' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSearchParams: () => new URLSearchParams(''), useRouter: () => ({ push: mockPush }), usePathname: () => '/datasets/ds-1/documents', diff --git a/web/__tests__/document-detail-navigation-fix.test.tsx b/web/__tests__/document-detail-navigation-fix.test.tsx index 6b348cd15b..5cb115830e 100644 --- a/web/__tests__/document-detail-navigation-fix.test.tsx +++ b/web/__tests__/document-detail-navigation-fix.test.tsx @@ -7,12 +7,12 @@ import type { Mock } from 'vitest' */ import { fireEvent, render, screen } from '@testing-library/react' -import { useRouter } from 'next/navigation' +import { useRouter } from '@/next/navigation' import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document' // Mock Next.js router const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(() => ({ push: mockPush, })), diff --git a/web/__tests__/embedded-user-id-auth.test.tsx b/web/__tests__/embedded-user-id-auth.test.tsx index 9231ac6199..cacd6331f8 100644 --- a/web/__tests__/embedded-user-id-auth.test.tsx +++ b/web/__tests__/embedded-user-id-auth.test.tsx @@ -8,7 +8,7 @@ const replaceMock = vi.fn() const backMock = vi.fn() const useSearchParamsMock = vi.fn(() => new URLSearchParams()) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(() => '/chatbot/test-app'), useRouter: vi.fn(() => ({ replace: replaceMock, diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx index 901218e76b..04597ccfeb 100644 --- a/web/__tests__/embedded-user-id-store.test.tsx +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -4,7 +4,7 @@ import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(() => '/chatbot/sample-app'), useSearchParams: vi.fn(() => { const params = new URLSearchParams() diff --git a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx index e2c18bcc4f..f3d3128ccb 100644 --- a/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx +++ b/web/__tests__/explore/sidebar-lifecycle-flow.test.tsx @@ -7,19 +7,23 @@ */ import type { InstalledApp } from '@/models/explore' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import Toast from '@/app/components/base/toast' import SideBar from '@/app/components/explore/sidebar' import { MediaType } from '@/hooks/use-breakpoints' import { AppModeEnum } from '@/types/app' +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), +})) + let mockMediaType: string = MediaType.pc const mockSegments = ['apps'] const mockPush = vi.fn() const mockUninstall = vi.fn() const mockUpdatePinStatus = vi.fn() let mockInstalledApps: InstalledApp[] = [] +let mockIsUninstallPending = false -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegments: () => mockSegments, useRouter: () => ({ push: mockPush, @@ -42,12 +46,22 @@ vi.mock('@/service/use-explore', () => ({ }), useUninstallApp: () => ({ mutateAsync: mockUninstall, + isPending: mockIsUninstallPending, }), useUpdateAppPinStatus: () => ({ mutateAsync: mockUpdatePinStatus, }), })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockToastAdd, + close: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, +})) + const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ id: overrides.id ?? 'app-1', uninstallable: overrides.uninstallable ?? false, @@ -74,7 +88,7 @@ describe('Sidebar Lifecycle Flow', () => { vi.clearAllMocks() mockMediaType = MediaType.pc mockInstalledApps = [] - vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + mockIsUninstallPending = false }) describe('Pin / Unpin / Delete Flow', () => { @@ -91,7 +105,7 @@ describe('Sidebar Lifecycle Flow', () => { await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true }) - expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', })) }) @@ -110,7 +124,7 @@ describe('Sidebar Lifecycle Flow', () => { await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false }) - expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', })) }) @@ -136,9 +150,9 @@ describe('Sidebar Lifecycle Flow', () => { // Step 4: Uninstall API called and success toast shown await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith('app-1') - expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', - message: 'common.api.remove', + title: 'common.api.remove', })) }) }) diff --git a/web/__tests__/plugins/plugin-install-flow.test.ts b/web/__tests__/plugins/plugin-install-flow.test.ts index 7ceca4535b..8edb6705d4 100644 --- a/web/__tests__/plugins/plugin-install-flow.test.ts +++ b/web/__tests__/plugins/plugin-install-flow.test.ts @@ -22,33 +22,6 @@ vi.mock('@/service/plugins', () => ({ checkTaskStatus: vi.fn(), })) -vi.mock('@/utils/semver', () => ({ - compareVersion: (a: string, b: string) => { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) - const [aMajor, aMinor = 0, aPatch = 0] = parse(a) - const [bMajor, bMinor = 0, bPatch = 0] = parse(b) - if (aMajor !== bMajor) - return aMajor > bMajor ? 1 : -1 - if (aMinor !== bMinor) - return aMinor > bMinor ? 1 : -1 - if (aPatch !== bPatch) - return aPatch > bPatch ? 1 : -1 - return 0 - }, - getLatestVersion: (versions: string[]) => { - return versions.sort((a, b) => { - const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number) - const [aMaj, aMin = 0, aPat = 0] = parse(a) - const [bMaj, bMin = 0, bPat = 0] = parse(b) - if (aMaj !== bMaj) - return bMaj - aMaj - if (aMin !== bMin) - return bMin - aMin - return bPat - aPat - })[0] - }, -})) - const { useGitHubReleases, useGitHubUpload } = await import( '@/app/components/plugins/install-plugin/hooks', ) diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx index 3292474bec..2fec054a47 100644 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ b/web/__tests__/share/text-generation-index-flow.test.tsx @@ -5,7 +5,7 @@ import TextGeneration from '@/app/components/share/text-generation' const useSearchParamsMock = vi.fn(() => new URLSearchParams()) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSearchParams: () => useSearchParamsMock(), })) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx index 6f60899c85..0c87fd1a4d 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/layout-main.tsx @@ -13,7 +13,6 @@ import { RiTerminalWindowLine, } from '@remixicon/react' import { useUnmount } from 'ahooks' -import { usePathname, useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -26,6 +25,7 @@ import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import dynamic from '@/next/dynamic' +import { usePathname, useRouter } from '@/next/navigation' import { fetchAppDetailDirect } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx index 5e7d98d191..4201d11490 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx @@ -7,7 +7,6 @@ import { RiEqualizer2Line, } from '@remixicon/react' import { useBoolean } from 'ahooks' -import { usePathname } from 'next/navigation' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -17,6 +16,7 @@ import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import Indicator from '@/app/components/header/indicator' import { useAppContext } from '@/context/app-context' +import { usePathname } from '@/next/navigation' import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps' import { cn } from '@/utils/classnames' import ConfigButton from './config-button' diff --git a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx index 4f3f724e62..730b76ee19 100644 --- a/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx +++ b/web/app/(commonLayout)/datasets/(datasetDetailLayout)/[datasetId]/layout-main.tsx @@ -9,7 +9,6 @@ import { RiFocus2Fill, RiFocus2Line, } from '@remixicon/react' -import { usePathname } from 'next/navigation' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -23,6 +22,7 @@ import DatasetDetailContext from '@/context/dataset-detail' import { useEventEmitterContextContext } from '@/context/event-emitter' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' +import { usePathname } from '@/next/navigation' import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset' import { cn } from '@/utils/classnames' diff --git a/web/app/(commonLayout)/datasets/layout.spec.tsx b/web/app/(commonLayout)/datasets/layout.spec.tsx index 5873f344d0..9c01cffba8 100644 --- a/web/app/(commonLayout)/datasets/layout.spec.tsx +++ b/web/app/(commonLayout)/datasets/layout.spec.tsx @@ -6,7 +6,7 @@ import DatasetsLayout from './layout' const mockReplace = vi.fn() const mockUseAppContext = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, }), diff --git a/web/app/(commonLayout)/datasets/layout.tsx b/web/app/(commonLayout)/datasets/layout.tsx index b543c42570..a465f8222b 100644 --- a/web/app/(commonLayout)/datasets/layout.tsx +++ b/web/app/(commonLayout)/datasets/layout.tsx @@ -1,11 +1,11 @@ 'use client' -import { useRouter } from 'next/navigation' import { useEffect } from 'react' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' import { ExternalApiPanelProvider } from '@/context/external-api-panel-context' import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context' +import { useRouter } from '@/next/navigation' export default function DatasetsLayout({ children }: { children: React.ReactNode }) { const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, currentWorkspace, isLoadingCurrentWorkspace } = useAppContext() diff --git a/web/app/(commonLayout)/education-apply/page.tsx b/web/app/(commonLayout)/education-apply/page.tsx index fce6fe1d5d..44ba5ee8ad 100644 --- a/web/app/(commonLayout)/education-apply/page.tsx +++ b/web/app/(commonLayout)/education-apply/page.tsx @@ -1,15 +1,15 @@ 'use client' -import { - useRouter, - useSearchParams, -} from 'next/navigation' import { useEffect, useMemo, } from 'react' import EducationApplyPage from '@/app/education-apply/education-apply-page' import { useProviderContext } from '@/context/provider-context' +import { + useRouter, + useSearchParams, +} from '@/next/navigation' export default function EducationApply() { const router = useRouter() diff --git a/web/app/(commonLayout)/role-route-guard.spec.tsx b/web/app/(commonLayout)/role-route-guard.spec.tsx index 87bf9be8af..ca1550f0b8 100644 --- a/web/app/(commonLayout)/role-route-guard.spec.tsx +++ b/web/app/(commonLayout)/role-route-guard.spec.tsx @@ -6,7 +6,7 @@ const mockReplace = vi.fn() const mockUseAppContext = vi.fn() let mockPathname = '/apps' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, useRouter: () => ({ replace: mockReplace, diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index 1c42be9d15..483dfef095 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -1,10 +1,10 @@ 'use client' import type { ReactNode } from 'react' -import { usePathname, useRouter } from 'next/navigation' import { useEffect } from 'react' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' +import { usePathname, useRouter } from '@/next/navigation' const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index d027ef8b7d..035da6be8a 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -9,7 +9,6 @@ import { RiInformation2Fill, } from '@remixicon/react' import { produce } from 'immer' -import { useParams } from 'next/navigation' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -21,6 +20,7 @@ import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-inp import Loading from '@/app/components/base/loading' import DifyLogo from '@/app/components/base/logo/dify-logo' import useDocumentTitle from '@/hooks/use-document-title' +import { useParams } from '@/next/navigation' import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share' import { cn } from '@/utils/classnames' diff --git a/web/app/(shareLayout)/components/authenticated-layout.tsx b/web/app/(shareLayout)/components/authenticated-layout.tsx index c874990448..9f956a8501 100644 --- a/web/app/(shareLayout)/components/authenticated-layout.tsx +++ b/web/app/(shareLayout)/components/authenticated-layout.tsx @@ -1,12 +1,12 @@ 'use client' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control' import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share' import { webAppLogout } from '@/service/webapp-auth' diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index a2b847f74f..402005752d 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC, PropsWithChildren } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { useWebAppStore } from '@/context/web-app-context' +import { useRouter, useSearchParams } from '@/next/navigation' import { fetchAccessToken } from '@/service/share' import { setWebAppAccessToken, setWebAppPassport, webAppLoginStatus, webAppLogout } from '@/service/webapp-auth' diff --git a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx index fbf45259e5..6a4e71f574 100644 --- a/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx @@ -1,14 +1,14 @@ 'use client' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' - import { useLocale } from '@/context/i18n' + +import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common' export default function CheckCode() { @@ -24,16 +24,16 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.emptyCode', { ns: 'login' }), + title: t('checkCode.emptyCode', { ns: 'login' }), }) return } if (!/\d{6}/.test(code)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.invalidCode', { ns: 'login' }), + title: t('checkCode.invalidCode', { ns: 'login' }), }) return } diff --git a/web/app/(shareLayout)/webapp-reset-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/page.tsx index 9b9a853cdd..08a42478aa 100644 --- a/web/app/(shareLayout)/webapp-reset-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/page.tsx @@ -1,18 +1,18 @@ 'use client' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' - import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' + +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { sendResetPasswordCode } from '@/service/common' export default function CheckCode() { @@ -27,14 +27,14 @@ export default function CheckCode() { const handleGetEMailVerificationCode = async () => { try { if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } @@ -48,15 +48,15 @@ export default function CheckCode() { router.push(`/webapp-reset-password/check-code?${params.toString()}`) } else if (res.code === 'account_not_found') { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.registrationNotAllowed', { ns: 'login' }), + title: t('error.registrationNotAllowed', { ns: 'login' }), }) } else { - Toast.notify({ + toast.add({ type: 'error', - message: res.data, + title: res.data, }) } } diff --git a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx index 9f59e8f9eb..22d2d22879 100644 --- a/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/set-password/page.tsx @@ -1,13 +1,13 @@ 'use client' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' +import { useRouter, useSearchParams } from '@/next/navigation' import { changeWebAppPasswordWithToken } from '@/service/common' import { cn } from '@/utils/classnames' @@ -24,9 +24,9 @@ const ChangePasswordForm = () => { const [showConfirmPassword, setShowConfirmPassword] = useState(false) const showErrorMessage = useCallback((message: string) => { - Toast.notify({ + toast.add({ type: 'error', - message, + title: message, }) }, []) diff --git a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx index afea9d668b..603369a858 100644 --- a/web/app/(shareLayout)/webapp-signin/check-code/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/check-code/page.tsx @@ -1,15 +1,15 @@ 'use client' import type { FormEvent } from 'react' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' +import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common' import { fetchAccessToken } from '@/service/share' import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth' @@ -43,23 +43,23 @@ export default function CheckCode() { try { const appCode = getAppCodeFromRedirectUrl() if (!code.trim()) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.emptyCode', { ns: 'login' }), + title: t('checkCode.emptyCode', { ns: 'login' }), }) return } if (!/\d{6}/.test(code)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.invalidCode', { ns: 'login' }), + title: t('checkCode.invalidCode', { ns: 'login' }), }) return } if (!redirectUrl || !appCode) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.redirectUrlMissing', { ns: 'login' }), + title: t('error.redirectUrlMissing', { ns: 'login' }), }) return } diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index 0776df036d..b7fb7036e8 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -1,11 +1,11 @@ 'use client' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect } from 'react' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { useGlobalPublicStore } from '@/context/global-public-context' +import { useRouter, useSearchParams } from '@/next/navigation' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' import { SSOProtocol } from '@/types/feature' @@ -17,9 +17,9 @@ const ExternalMemberSSOAuth = () => { const redirectUrl = searchParams.get('redirect_url') const showErrorToast = (message: string) => { - Toast.notify({ + toast.add({ type: 'error', - message, + title: message, }) } diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx index 5aa9d9f141..7a20713e05 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx @@ -1,13 +1,13 @@ import { noop } from 'es-toolkit/function' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' +import { useRouter, useSearchParams } from '@/next/navigation' import { sendWebAppEMailLoginCode } from '@/service/common' export default function MailAndCodeAuth() { @@ -22,14 +22,14 @@ export default function MailAndCodeAuth() { const handleGetEMailVerificationCode = async () => { try { if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } diff --git a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx index e49559401d..bbc4cc8efd 100644 --- a/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx @@ -1,15 +1,15 @@ 'use client' import { noop } from 'es-toolkit/function' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import { useWebAppStore } from '@/context/web-app-context' +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { webAppLogin } from '@/service/common' import { fetchAccessToken } from '@/service/share' import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth' @@ -46,25 +46,25 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut const appCode = getAppCodeFromRedirectUrl() const handleEmailPasswordLogin = async () => { if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } if (!password?.trim()) { - Toast.notify({ type: 'error', message: t('error.passwordEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.passwordEmpty', { ns: 'login' }) }) return } if (!redirectUrl || !appCode) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.redirectUrlMissing', { ns: 'login' }), + title: t('error.redirectUrlMissing', { ns: 'login' }), }) return } @@ -94,15 +94,15 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut router.replace(decodeURIComponent(redirectUrl)) } else { - Toast.notify({ + toast.add({ type: 'error', - message: res.data, + title: res.data, }) } } catch (e: any) { if (e.code === 'authentication_failed') - Toast.notify({ type: 'error', message: e.message }) + toast.add({ type: 'error', title: e.message }) } finally { setIsLoading(false) diff --git a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx index d8f3854868..fd12c2060f 100644 --- a/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/sso-auth.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' +import { useRouter, useSearchParams } from '@/next/navigation' import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share' import { SSOProtocol } from '@/types/feature' @@ -37,9 +37,9 @@ const SSOAuth: FC = ({ const handleSSOLogin = () => { const appCode = getAppCodeFromRedirectUrl() if (!redirectUrl || !appCode) { - Toast.notify({ + toast.add({ type: 'error', - message: 'invalid redirect URL or app code', + title: t('error.invalidRedirectUrlOrAppCode', { ns: 'login' }), }) return } @@ -66,9 +66,9 @@ const SSOAuth: FC = ({ }) } else { - Toast.notify({ + toast.add({ type: 'error', - message: 'invalid SSO protocol', + title: t('error.invalidSSOProtocol', { ns: 'login' }), }) setIsLoading(false) } diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index b15145346f..7ee08d66ae 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,12 +1,12 @@ 'use client' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { IS_CE_EDITION } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' +import Link from '@/next/link' import { LicenseStatus } from '@/types/feature' import { cn } from '@/utils/classnames' import MailAndCodeAuth from './components/mail-and-code-auth' diff --git a/web/app/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index b3ad1d48a6..a5c2528cc7 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -8,6 +7,7 @@ import AppUnavailable from '@/app/components/base/app-unavailable' import { useGlobalPublicStore } from '@/context/global-public-context' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' +import { useRouter, useSearchParams } from '@/next/navigation' import { webAppLogout } from '@/service/webapp-auth' import ExternalMemberSsoAuth from './components/external-member-sso-auth' import NormalForm from './normalForm' diff --git a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx index c146174ea9..f0dfd4f12f 100644 --- a/web/app/account/(commonLayout)/account-page/email-change-modal.tsx +++ b/web/app/account/(commonLayout)/account-page/email-change-modal.tsx @@ -1,7 +1,6 @@ import type { ResponseError } from '@/service/fetch' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -10,6 +9,7 @@ import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast/context' +import { useRouter } from '@/next/navigation' import { checkEmailExisted, resetEmail, diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 07b685b8c5..0b3541ae9c 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -3,7 +3,6 @@ import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/r import { RiGraduationCapFill, } from '@remixicon/react' -import { useRouter } from 'next/navigation' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' @@ -11,6 +10,7 @@ import { Avatar } from '@/app/components/base/avatar' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' import { useProviderContext } from '@/context/provider-context' +import { useRouter } from '@/next/navigation' import { useLogout, useUserProfile } from '@/service/use-common' export type IAppSelector = { diff --git a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx index 65a58c936e..e0f00189b2 100644 --- a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx @@ -1,10 +1,10 @@ 'use client' -import Link from 'next/link' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import { useAppContext } from '@/context/app-context' +import Link from '@/next/link' import { useSendDeleteAccountEmail } from '../state' type DeleteAccountProps = { diff --git a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx index 67fea3c141..ae73d778f8 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -1,5 +1,4 @@ 'use client' -import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -7,6 +6,7 @@ import CustomDialog from '@/app/components/base/dialog' import Textarea from '@/app/components/base/textarea' import Toast from '@/app/components/base/toast' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import { useDeleteAccountFeedback } from '../state' diff --git a/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx index d7590c27f9..5d76f13f34 100644 --- a/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/verify-email.tsx @@ -1,10 +1,10 @@ 'use client' -import Link from 'next/link' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Countdown from '@/app/components/signin/countdown' +import Link from '@/next/link' import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state' const CODE_EXP = /[A-Z\d]{6}/gi diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index bb58be87a8..5ef84a8f1e 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -1,11 +1,11 @@ 'use client' import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' -import { useRouter } from 'next/navigation' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import DifyLogo from '@/app/components/base/logo/dify-logo' import { useGlobalPublicStore } from '@/context/global-public-context' +import { useRouter } from '@/next/navigation' import Avatar from './avatar' const Header = () => { diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index 835a1e702e..30cfdd25d3 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -7,16 +7,16 @@ import { RiMailLine, RiTranslate2, } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import { useRouter, useSearchParams } from '@/next/navigation' import { useIsLogin, useUserProfile } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' @@ -91,9 +91,9 @@ export default function OAuthAuthorize() { globalThis.location.href = url.toString() } catch (err: any) { - Toast.notify({ + toast.add({ type: 'error', - message: `${t('error.authorizeFailed', { ns: 'oauth' })}: ${err.message}`, + title: `${t('error.authorizeFailed', { ns: 'oauth' })}: ${err.message}`, }) } } @@ -102,10 +102,10 @@ export default function OAuthAuthorize() { const invalidParams = !client_id || !redirect_uri if ((invalidParams || isError) && !hasNotifiedRef.current) { hasNotifiedRef.current = true - Toast.notify({ + toast.add({ type: 'error', - message: invalidParams ? t('error.invalidParams', { ns: 'oauth' }) : t('error.authAppInfoFetchFailed', { ns: 'oauth' }), - duration: 0, + title: invalidParams ? t('error.invalidParams', { ns: 'oauth' }) : t('error.authAppInfoFetchFailed', { ns: 'oauth' }), + timeout: 0, }) } }, [client_id, redirect_uri, isError]) diff --git a/web/app/activate/activateForm.tsx b/web/app/activate/activateForm.tsx index 421b816652..418d3b8bb1 100644 --- a/web/app/activate/activateForm.tsx +++ b/web/app/activate/activateForm.tsx @@ -1,11 +1,11 @@ 'use client' -import { useRouter, useSearchParams } from 'next/navigation' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' - import useDocumentTitle from '@/hooks/use-document-title' + +import { useRouter, useSearchParams } from '@/next/navigation' import { useInvitationCheck } from '@/service/use-common' import { cn } from '@/utils/classnames' diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index bf7aa39580..e08ece6666 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -2,13 +2,13 @@ import type { ReactNode } from 'react' import Cookies from 'js-cookie' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { parseAsBoolean, useQueryState } from 'nuqs' import { useCallback, useEffect, useState } from 'react' import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { sendGAEvent } from '@/utils/gtag' import { fetchSetupStatusWithCache } from '@/utils/setup-status' import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect' diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index 89db80e0f1..b2e1e92bbb 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ vi.mock('zustand/react/shallow', () => ({ useShallow: (fn: unknown) => fn, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, })) diff --git a/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx b/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx index fb19833dd2..a3868a8330 100644 --- a/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/text-squeeze-fix-verification.spec.tsx @@ -7,7 +7,7 @@ import { render } from '@testing-library/react' import * as React from 'react' // Mock Next.js navigation -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: () => 'overview', })) diff --git a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx index f8612e8057..2f98089e40 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx +++ b/web/app/components/app-sidebar/app-info/__tests__/app-info-modals.spec.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import { AppModeEnum } from '@/types/app' import AppInfoModals from '../app-info-modals' -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (loader: () => Promise<{ default: React.ComponentType }>) => { const LazyComp = React.lazy(loader) return function DynamicWrapper(props: Record) { diff --git a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts index 6104e2b641..deea28ce3e 100644 --- a/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts +++ b/web/app/components/app-sidebar/app-info/__tests__/use-app-info-actions.spec.ts @@ -23,7 +23,7 @@ let mockAppDetail: Record | undefined = { icon_background: '#FFEAD5', } -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }), })) diff --git a/web/app/components/app-sidebar/app-info/app-info-modals.tsx b/web/app/components/app-sidebar/app-info/app-info-modals.tsx index 232afb18c7..6b76be87bb 100644 --- a/web/app/components/app-sidebar/app-info/app-info-modals.tsx +++ b/web/app/components/app-sidebar/app-info/app-info-modals.tsx @@ -4,6 +4,7 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { App, AppSSO } from '@/types/app' import * as React from 'react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' import dynamic from '@/next/dynamic' @@ -42,6 +43,7 @@ const AppInfoModals = ({ onConfirmDelete, }: AppInfoModalsProps) => { const { t } = useTranslation() + const [confirmDeleteInput, setConfirmDeleteInput] = useState('') return ( <> @@ -88,8 +90,16 @@ const AppInfoModals = ({ title={t('deleteAppConfirmTitle', { ns: 'app' })} content={t('deleteAppConfirmContent', { ns: 'app' })} isShow + confirmInputLabel={t('deleteAppConfirmInputLabel', { ns: 'app', appName: appDetail.name })} + confirmInputPlaceholder={t('deleteAppConfirmInputPlaceholder', { ns: 'app' })} + confirmInputValue={confirmDeleteInput} + onConfirmInputChange={setConfirmDeleteInput} + confirmInputMatchValue={appDetail.name} onConfirm={onConfirmDelete} - onCancel={closeModal} + onCancel={() => { + setConfirmDeleteInput('') + closeModal() + }} /> )} {activeModal === 'importDSL' && ( diff --git a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts index 800f21de44..55ec13e506 100644 --- a/web/app/components/app-sidebar/app-info/use-app-info-actions.ts +++ b/web/app/components/app-sidebar/app-info/use-app-info-actions.ts @@ -1,7 +1,6 @@ import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { EnvironmentVariable } from '@/app/components/workflow/types' -import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -9,6 +8,7 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { ToastContext } from '@/app/components/base/toast/context' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useProviderContext } from '@/context/provider-context' +import { useRouter } from '@/next/navigation' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { useInvalidateAppList } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx index 512f9490c2..1df6fa79b7 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/dropdown-callbacks.spec.tsx @@ -80,7 +80,7 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ ...overrides, }) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }), })) diff --git a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx index be27e247d7..a1e275d731 100644 --- a/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx @@ -90,7 +90,7 @@ const createDataset = (overrides: Partial = {}): DataSet => ({ ...overrides, }) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, }), diff --git a/web/app/components/app-sidebar/dataset-info/dropdown.tsx b/web/app/components/app-sidebar/dataset-info/dropdown.tsx index 96127c4210..528bac831f 100644 --- a/web/app/components/app-sidebar/dataset-info/dropdown.tsx +++ b/web/app/components/app-sidebar/dataset-info/dropdown.tsx @@ -1,11 +1,11 @@ import type { DataSet } from '@/models/datasets' import { RiMoreFill } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' +import { useRouter } from '@/next/navigation' import { checkIsUsedInApp, deleteDataset } from '@/service/datasets' import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { useInvalid } from '@/service/use-base' diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index e24b005d01..13fde97f89 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -1,12 +1,12 @@ import type { NavIcon } from './nav-link' import { useHover, useKeyPress } from 'ahooks' -import { usePathname } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useStore as useAppStore } from '@/app/components/app/store' import { useEventEmitterContextContext } from '@/context/event-emitter' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { usePathname } from '@/next/navigation' import { cn } from '@/utils/classnames' import Divider from '../base/divider' import { getKeyboardKeyCodeBySystem } from '../workflow/utils' diff --git a/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx b/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx index 04ca7bd0e4..fe46290002 100644 --- a/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx @@ -4,12 +4,12 @@ import * as React from 'react' import NavLink from '..' // Mock Next.js navigation -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: () => 'overview', })) // Mock Next.js Link component -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: function MockLink({ children, href, className, title }: { children: React.ReactNode, href: string, className?: string, title?: string }) { return ( diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index d69ed8590e..cf986a7407 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -1,8 +1,8 @@ 'use client' import type { RemixiconComponentType } from '@remixicon/react' -import Link from 'next/link' -import { useSelectedLayoutSegment } from 'next/navigation' import * as React from 'react' +import Link from '@/next/link' +import { useSelectedLayoutSegment } from '@/next/navigation' import { cn } from '@/utils/classnames' export type NavIcon = React.ComponentType< diff --git a/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx index 7f71247d56..8c6e626b45 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/index.spec.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import ContextVar from './index' // Mock external dependencies only -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/test', })) diff --git a/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx index aa8dae813f..6704fa0afd 100644 --- a/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx +++ b/web/app/components/app/configuration/dataset-config/context-var/var-picker.spec.tsx @@ -5,7 +5,7 @@ import * as React from 'react' import VarPicker from './var-picker' // Mock external dependencies only -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/test', })) diff --git a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx index 91e5353cc4..8c2fb77c20 100644 --- a/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx +++ b/web/app/components/app/configuration/dataset-config/select-dataset/index.tsx @@ -2,7 +2,6 @@ import type { FC } from 'react' import type { DataSet } from '@/models/datasets' import { useInfiniteScroll } from 'ahooks' -import Link from 'next/link' import * as React from 'react' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -14,6 +13,7 @@ import Modal from '@/app/components/base/modal' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import FeatureIcon from '@/app/components/header/account-setting/model-provider-page/model-selector/feature-icon' import { useKnowledge } from '@/hooks/use-knowledge' +import Link from '@/next/link' import { useInfiniteDatasets } from '@/service/knowledge/use-dataset' import { cn } from '@/utils/classnames' diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx index 48141d0045..a75516a43f 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.spec.tsx @@ -155,7 +155,7 @@ vi.mock('@/service/debug', () => ({ stopChatMessageResponding: mockStopChatMessageResponding, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/test', useParams: () => ({}), diff --git a/web/app/components/app/configuration/index.tsx b/web/app/components/app/configuration/index.tsx index 0e6ffb1e84..aa1bbe0a16 100644 --- a/web/app/components/app/configuration/index.tsx +++ b/web/app/components/app/configuration/index.tsx @@ -23,7 +23,6 @@ import { useBoolean, useGetState } from 'ahooks' import { clone } from 'es-toolkit/object' import { isEqual } from 'es-toolkit/predicate' import { produce } from 'immer' -import { usePathname } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -72,6 +71,7 @@ import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { PromptMode } from '@/models/debug' +import { usePathname } from '@/next/navigation' import { fetchAppDetailDirect, updateAppModelConfig } from '@/service/apps' import { fetchDatasets } from '@/service/datasets' import { fetchCollectionList } from '@/service/tools' diff --git a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx index e0f459ee75..a9b65a4ae9 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx @@ -39,8 +39,8 @@ vi.mock('../app-card', () => ({ vi.mock('@/app/components/explore/create-app-modal', () => ({ default: () =>
, })) -vi.mock('@/app/components/base/toast', () => ({ - default: { notify: vi.fn() }, +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { add: vi.fn() }, })) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), @@ -62,7 +62,7 @@ vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ vi.mock('@/utils/app-redirection', () => ({ getRedirection: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), })) diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx index b967ba7d55..8b1876be04 100644 --- a/web/app/components/app/create-app-dialog/app-list/index.tsx +++ b/web/app/components/app/create-app-dialog/app-list/index.tsx @@ -4,7 +4,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo import type { App } from '@/models/explore' import { RiRobot2Line } from '@remixicon/react' import { useDebounceFn } from 'ahooks' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,12 +12,13 @@ import { trackEvent } from '@/app/components/base/amplitude' import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import CreateAppModal from '@/app/components/explore/create-app-modal' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { DSLImportMode } from '@/models/app' +import { useRouter } from '@/next/navigation' import { importDSL } from '@/service/apps' import { fetchAppDetail } from '@/service/explore' import { useExploreAppList } from '@/service/use-explore' @@ -137,9 +137,9 @@ const Apps = ({ }) setIsShowCreateModal(false) - Toast.notify({ + toast.add({ type: 'success', - message: t('newApp.appCreated', { ns: 'app' }), + title: t('newApp.appCreated', { ns: 'app' }), }) if (onSuccess) onSuccess() @@ -149,7 +149,7 @@ const Apps = ({ getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push) } catch { - Toast.notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) }) + toast.add({ type: 'error', title: t('newApp.appCreateFailed', { ns: 'app' }) }) } } diff --git a/web/app/components/app/create-app-modal/index.spec.tsx b/web/app/components/app/create-app-modal/index.spec.tsx index a9adb17582..c253fcd457 100644 --- a/web/app/components/app/create-app-modal/index.spec.tsx +++ b/web/app/components/app/create-app-modal/index.spec.tsx @@ -1,13 +1,13 @@ import type { App } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { useRouter } from 'next/navigation' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { trackEvent } from '@/app/components/base/amplitude' - import { ToastContext } from '@/app/components/base/toast/context' + import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' +import { useRouter } from '@/next/navigation' import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' @@ -23,7 +23,7 @@ vi.mock('ahooks', () => ({ useKeyPress: vi.fn(), useHover: () => false, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), })) vi.mock('@/app/components/base/amplitude', () => ({ diff --git a/web/app/components/app/create-app-modal/index.tsx b/web/app/components/app/create-app-modal/index.tsx index 1c22913bb1..556773c341 100644 --- a/web/app/components/app/create-app-modal/index.tsx +++ b/web/app/components/app/create-app-modal/index.tsx @@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker' import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' -import { useRouter } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -22,6 +21,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' +import { useRouter } from '@/next/navigation' import { createApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index a0c8360c29..eaaee50973 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -4,7 +4,6 @@ import type { MouseEventHandler } from 'react' import { RiCloseLine } from '@remixicon/react' import { useDebounceFn, useKeyPress } from 'ahooks' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -22,6 +21,7 @@ import { DSLImportMode, DSLImportStatus, } from '@/models/app' +import { useRouter } from '@/next/navigation' import { importDSL, importDSLConfirm, diff --git a/web/app/components/app/log-annotation/index.spec.tsx b/web/app/components/app/log-annotation/index.spec.tsx index c7c654e870..de33ae6f66 100644 --- a/web/app/components/app/log-annotation/index.spec.tsx +++ b/web/app/components/app/log-annotation/index.spec.tsx @@ -7,7 +7,7 @@ import { AppModeEnum } from '@/types/app' import LogAnnotation from './index' const mockRouterPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), diff --git a/web/app/components/app/log-annotation/index.tsx b/web/app/components/app/log-annotation/index.tsx index ca6182603d..c5c21289df 100644 --- a/web/app/components/app/log-annotation/index.tsx +++ b/web/app/components/app/log-annotation/index.tsx @@ -1,6 +1,5 @@ 'use client' import type { FC } from 'react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -11,6 +10,7 @@ import WorkflowLog from '@/app/components/app/workflow-log' import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type' import Loading from '@/app/components/base/loading' import TabSlider from '@/app/components/base/tab-slider-plain' +import { useRouter } from '@/next/navigation' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' diff --git a/web/app/components/app/log/empty-element.tsx b/web/app/components/app/log/empty-element.tsx index e42a1df7d5..95b0e7f03f 100644 --- a/web/app/components/app/log/empty-element.tsx +++ b/web/app/components/app/log/empty-element.tsx @@ -1,9 +1,9 @@ 'use client' import type { FC, SVGProps } from 'react' import type { App } from '@/types/app' -import Link from 'next/link' import * as React from 'react' import { Trans, useTranslation } from 'react-i18next' +import Link from '@/next/link' import { AppModeEnum } from '@/types/app' import { getRedirectionPath } from '@/utils/app-redirection' import { basePath } from '@/utils/var' diff --git a/web/app/components/app/log/index.tsx b/web/app/components/app/log/index.tsx index e96c9ce0c9..59f454f754 100644 --- a/web/app/components/app/log/index.tsx +++ b/web/app/components/app/log/index.tsx @@ -4,13 +4,13 @@ import type { App } from '@/types/app' import { useDebounce } from 'ahooks' import dayjs from 'dayjs' import { omit } from 'es-toolkit/object' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import Pagination from '@/app/components/base/pagination' import { APP_PAGE_LIMIT } from '@/config' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { useChatConversations, useCompletionConversations } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import EmptyElement from './empty-element' diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 146af44a10..453c7c9d4c 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -14,7 +14,6 @@ import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import { get } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -38,6 +37,7 @@ import { WorkflowContextProvider } from '@/app/components/workflow/context' import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' +import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index 1b02e54d5f..42cf4d8618 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -14,7 +14,6 @@ import { RiVerifiedBadgeLine, RiWindowLine, } from '@remixicon/react' -import { usePathname, useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -34,6 +33,7 @@ import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import { AccessMode } from '@/models/access-control' +import { usePathname, useRouter } from '@/next/navigation' import { useAppWhiteListSubjects } from '@/service/access-control' import { fetchAppDetailDirect } from '@/service/apps' import { useAppWorkflow } from '@/service/use-workflow' diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index f7c9e309ab..13dacde424 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -4,7 +4,6 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker' import type { AppDetailResponse } from '@/models/app' import type { AppIconType, AppSSO, Language } from '@/types/app' import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -26,6 +25,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { languages } from '@/i18n-config/language' +import Link from '@/next/link' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' diff --git a/web/app/components/app/overview/trigger-card.tsx b/web/app/components/app/overview/trigger-card.tsx index 1f0f0dca56..09e3a08393 100644 --- a/web/app/components/app/overview/trigger-card.tsx +++ b/web/app/components/app/overview/trigger-card.tsx @@ -3,7 +3,6 @@ import type { AppDetailResponse } from '@/models/app' import type { AppTrigger } from '@/service/use-tools' import type { AppSSO } from '@/types/app' import type { I18nKeysByPrefix } from '@/types/i18n' -import Link from 'next/link' import * as React from 'react' import { useTranslation } from 'react-i18next' import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' @@ -13,6 +12,7 @@ import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-s import { BlockEnum } from '@/app/components/workflow/types' import { useAppContext } from '@/context/app-context' import { useDocLink } from '@/context/i18n' +import Link from '@/next/link' import { useAppTriggers, diff --git a/web/app/components/app/switch-app-modal/index.spec.tsx b/web/app/components/app/switch-app-modal/index.spec.tsx index c905d79b31..53007b986b 100644 --- a/web/app/components/app/switch-app-modal/index.spec.tsx +++ b/web/app/components/app/switch-app-modal/index.spec.tsx @@ -11,7 +11,7 @@ import SwitchAppModal from './index' const mockPush = vi.fn() const mockReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, replace: mockReplace, diff --git a/web/app/components/app/switch-app-modal/index.tsx b/web/app/components/app/switch-app-modal/index.tsx index 8caa07c187..7c3269d52c 100644 --- a/web/app/components/app/switch-app-modal/index.tsx +++ b/web/app/components/app/switch-app-modal/index.tsx @@ -3,7 +3,6 @@ import type { App } from '@/types/app' import { RiCloseLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -20,6 +19,7 @@ import AppsFull from '@/app/components/billing/apps-full-in-dialog' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' +import { useRouter } from '@/next/navigation' import { deleteApp, switchApp } from '@/service/apps' import { AppModeEnum } from '@/types/app' import { getRedirection } from '@/utils/app-redirection' diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 22358805a7..d22375a292 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -16,7 +16,6 @@ import { } from '@remixicon/react' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' -import { useParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -30,6 +29,7 @@ import Loading from '@/app/components/base/loading' import { Markdown } from '@/app/components/base/markdown' import NewAudioButton from '@/app/components/base/new-audio-button' import Toast from '@/app/components/base/toast' +import { useParams } from '@/next/navigation' import { fetchTextGenerationMessage } from '@/service/debug' import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share' import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow' diff --git a/web/app/components/app/text-generate/saved-items/index.spec.tsx b/web/app/components/app/text-generate/saved-items/index.spec.tsx index f04a37bded..b45a1cca6c 100644 --- a/web/app/components/app/text-generate/saved-items/index.spec.tsx +++ b/web/app/components/app/text-generate/saved-items/index.spec.tsx @@ -10,7 +10,7 @@ import SavedItems from './index' vi.mock('copy-to-clipboard', () => ({ default: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({}), usePathname: () => '/', })) diff --git a/web/app/components/app/workflow-log/detail.spec.tsx b/web/app/components/app/workflow-log/detail.spec.tsx index 1ed7193d42..806c6e71b2 100644 --- a/web/app/components/app/workflow-log/detail.spec.tsx +++ b/web/app/components/app/workflow-log/detail.spec.tsx @@ -19,7 +19,7 @@ import DetailPanel from './detail' // ============================================================================ const mockRouterPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), diff --git a/web/app/components/app/workflow-log/detail.tsx b/web/app/components/app/workflow-log/detail.tsx index ce85653e71..99d2c70228 100644 --- a/web/app/components/app/workflow-log/detail.tsx +++ b/web/app/components/app/workflow-log/detail.tsx @@ -1,12 +1,12 @@ 'use client' import type { FC } from 'react' import { RiCloseLine, RiPlayLargeLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import { useTranslation } from 'react-i18next' import { useStore } from '@/app/components/app/store' import TooltipPlus from '@/app/components/base/tooltip' import { WorkflowContextProvider } from '@/app/components/workflow/context' import Run from '@/app/components/workflow/run' +import { useRouter } from '@/next/navigation' type ILogDetail = { runID: string diff --git a/web/app/components/app/workflow-log/index.spec.tsx b/web/app/components/app/workflow-log/index.spec.tsx index f8e3f16e25..92f8eddf83 100644 --- a/web/app/components/app/workflow-log/index.spec.tsx +++ b/web/app/components/app/workflow-log/index.spec.tsx @@ -47,13 +47,13 @@ vi.mock('ahooks', () => ({ }, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), }), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => {children}, })) diff --git a/web/app/components/app/workflow-log/list.spec.tsx b/web/app/components/app/workflow-log/list.spec.tsx index 760d222692..36cc911248 100644 --- a/web/app/components/app/workflow-log/list.spec.tsx +++ b/web/app/components/app/workflow-log/list.spec.tsx @@ -23,7 +23,7 @@ import WorkflowAppLogList from './list' // ============================================================================ const mockRouterPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 9bc23ce199..86c87e0c5b 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -11,7 +11,7 @@ import AppCard from '../app-card' // Mock next/navigation const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), @@ -111,7 +111,7 @@ vi.mock('@/utils/time', () => ({ })) // Mock dynamic imports -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (importFn: () => Promise) => { const fnString = importFn.toString() @@ -543,6 +543,11 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('popover-trigger')) fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + + // Fill in the confirmation input with app name + const deleteInput = screen.getByRole('textbox') + fireEvent.change(deleteInput, { target: { value: mockApp.name } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { @@ -556,6 +561,11 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('popover-trigger')) fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + + // Fill in the confirmation input with app name + const deleteInput = screen.getByRole('textbox') + fireEvent.change(deleteInput, { target: { value: mockApp.name } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { @@ -572,6 +582,11 @@ describe('AppCard', () => { fireEvent.click(screen.getByTestId('popover-trigger')) fireEvent.click(await screen.findByRole('button', { name: 'common.operation.delete' })) expect(await screen.findByRole('alertdialog')).toBeInTheDocument() + + // Fill in the confirmation input with app name + const deleteInput = screen.getByRole('textbox') + fireEvent.change(deleteInput, { target: { value: mockApp.name } }) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' })) await waitFor(() => { diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index 989bf6a788..877c392e6d 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -8,7 +8,7 @@ import List from '../list' const mockReplace = vi.fn() const mockRouter = { replace: mockReplace } -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => mockRouter, useSearchParams: () => new URLSearchParams(''), })) @@ -124,7 +124,7 @@ vi.mock('@/hooks/use-pay', () => ({ CheckModal: () => null, })) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (importFn: () => Promise) => { const fnString = importFn.toString() diff --git a/web/app/components/apps/__tests__/new-app-card.spec.tsx b/web/app/components/apps/__tests__/new-app-card.spec.tsx index f4c357b9f9..9c98936bea 100644 --- a/web/app/components/apps/__tests__/new-app-card.spec.tsx +++ b/web/app/components/apps/__tests__/new-app-card.spec.tsx @@ -4,7 +4,7 @@ import * as React from 'react' import CreateAppCard from '../new-app-card' const mockReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, }), @@ -18,7 +18,7 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (importFn: () => Promise<{ default: React.ComponentType }>) => { const fnString = importFn.toString() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 6a4a2181d6..9a8abf6443 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -7,7 +7,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { App } from '@/types/app' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -36,6 +35,7 @@ import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { AccessMode } from '@/models/access-control' import dynamic from '@/next/dynamic' +import { useRouter } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control' import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' @@ -82,6 +82,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { const [showDuplicateModal, setShowDuplicateModal] = useState(false) const [showSwitchModal, setShowSwitchModal] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false) + const [confirmDeleteInput, setConfirmDeleteInput] = useState('') const [showAccessControl, setShowAccessControl] = useState(false) const [secretEnvList, setSecretEnvList] = useState([]) const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation() @@ -100,6 +101,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { } finally { setShowConfirmDelete(false) + setConfirmDeleteInput('') } }, [app.id, mutateDeleteApp, notify, onPlanInfoChanged, t]) @@ -108,6 +110,8 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { return setShowConfirmDelete(open) + if (!open) + setConfirmDeleteInput('') }, [isDeleting]) const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({ @@ -521,12 +525,28 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { {t('deleteAppConfirmContent', { ns: 'app' })} +
+ + setConfirmDeleteInput(e.target.value)} + /> +
{t('operation.cancel', { ns: 'common' })} - + {t('operation.confirm', { ns: 'common' })} diff --git a/web/app/components/apps/footer.tsx b/web/app/components/apps/footer.tsx index 3a0e960e0d..9147ccf6a6 100644 --- a/web/app/components/apps/footer.tsx +++ b/web/app/components/apps/footer.tsx @@ -1,7 +1,7 @@ import { RiDiscordFill, RiDiscussLine, RiGithubFill } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' type CustomLinkProps = { href: string diff --git a/web/app/components/apps/new-app-card.tsx b/web/app/components/apps/new-app-card.tsx index a14b10098f..7741190b8c 100644 --- a/web/app/components/apps/new-app-card.tsx +++ b/web/app/components/apps/new-app-card.tsx @@ -1,9 +1,5 @@ 'use client' -import { - useRouter, - useSearchParams, -} from 'next/navigation' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,6 +9,10 @@ import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons import AppListContext from '@/context/app-list-context' import { useProviderContext } from '@/context/provider-context' import dynamic from '@/next/dynamic' +import { + useRouter, + useSearchParams, +} from '@/next/navigation' import { cn } from '@/utils/classnames' const CreateAppModal = dynamic(() => import('@/app/components/app/create-app-modal'), { diff --git a/web/app/components/base/audio-btn/__tests__/index.spec.tsx b/web/app/components/base/audio-btn/__tests__/index.spec.tsx index c8d8ee851b..8f6c26d12b 100644 --- a/web/app/components/base/audio-btn/__tests__/index.spec.tsx +++ b/web/app/components/base/audio-btn/__tests__/index.spec.tsx @@ -1,14 +1,14 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import i18next from 'i18next' -import { useParams, usePathname } from 'next/navigation' +import { useParams, usePathname } from '@/next/navigation' import AudioBtn from '../index' const mockPlayAudio = vi.fn() const mockPauseAudio = vi.fn() const mockGetAudioPlayer = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(), usePathname: vi.fn(), })) diff --git a/web/app/components/base/audio-btn/index.tsx b/web/app/components/base/audio-btn/index.tsx index 8bea3193c8..47fefe19e5 100644 --- a/web/app/components/base/audio-btn/index.tsx +++ b/web/app/components/base/audio-btn/index.tsx @@ -1,10 +1,10 @@ 'use client' import { t } from 'i18next' -import { useParams, usePathname } from 'next/navigation' import { useState } from 'react' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import Loading from '@/app/components/base/loading' import Tooltip from '@/app/components/base/tooltip' +import { useParams, usePathname } from '@/next/navigation' import s from './style.module.css' type AudioBtnProps = { diff --git a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx index 60a5da5d49..bd5f01bcda 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/chat-wrapper.spec.tsx @@ -25,7 +25,7 @@ vi.mock('../context', () => ({ useChatWithHistoryContext: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index 84bf9134d6..d75f9897a7 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -22,7 +22,7 @@ vi.mock('../context', () => ({ ChatWithHistoryContext: { Provider: ({ children }: { children: React.ReactNode }) =>
{children}
}, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), diff --git a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx index 167cc7b385..e306569140 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx @@ -26,7 +26,7 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index 896161f66c..bb62869f21 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -87,7 +87,7 @@ vi.mock('@/context/global-public-context', () => ({ })) // Mock next/navigation -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/test', })) diff --git a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx index da989d8b7c..f5b261d5f3 100644 --- a/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat/__tests__/hooks.spec.tsx @@ -1,8 +1,8 @@ import type { ChatConfig, ChatItemInTree } from '../../types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import { act, renderHook } from '@testing-library/react' -import { useParams, usePathname } from 'next/navigation' import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost } from '@/service/base' import { useChat } from '../hooks' @@ -28,7 +28,7 @@ vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: vi.fn().mockReturnValue('10:00 AM') }), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(() => ({})), usePathname: vi.fn(() => ''), useRouter: vi.fn(() => ({})), diff --git a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx index baff417669..836397a586 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/operation.spec.tsx @@ -111,7 +111,7 @@ vi.mock('@/app/components/base/chat/chat/log', () => ({ default: () => , })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(() => ({ appId: 'test-app' })), usePathname: vi.fn(() => '/apps/test-app'), })) diff --git a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx index cb1d0f2a55..f628b7de82 100644 --- a/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/__tests__/index.spec.tsx @@ -208,7 +208,7 @@ vi.mock('../../check-input-forms-hooks', () => ({ // --------------------------------------------------------------------------- // Next.js navigation // --------------------------------------------------------------------------- -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: 'test-token' }), useRouter: () => ({ push: vi.fn() }), usePathname: () => '/test', diff --git a/web/app/components/base/chat/chat/citation/popup.tsx b/web/app/components/base/chat/chat/citation/popup.tsx index 7dc2baeb88..3a1d4bf251 100644 --- a/web/app/components/base/chat/chat/citation/popup.tsx +++ b/web/app/components/base/chat/chat/citation/popup.tsx @@ -1,6 +1,5 @@ import type { FC, MouseEvent } from 'react' import type { Resources } from './index' -import Link from 'next/link' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import FileIcon from '@/app/components/base/file-icon' @@ -9,6 +8,7 @@ import { 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' import ProgressTooltip from './progress-tooltip' diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index 307fd52443..9c06f49b3d 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -15,7 +15,6 @@ import type { import { uniqBy } from 'es-toolkit/compat' import { noop } from 'es-toolkit/function' import { produce, setAutoFreeze } from 'immer' -import { useParams, usePathname } from 'next/navigation' import { useCallback, useEffect, @@ -33,6 +32,7 @@ import { import { useToastContext } from '@/app/components/base/toast/context' import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types' import useTimestamp from '@/hooks/use-timestamp' +import { useParams, usePathname } from '@/next/navigation' import { sseGet, ssePost, diff --git a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx index aad2d3d09b..689a9e0439 100644 --- a/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/inputs-form/__tests__/content.spec.tsx @@ -9,7 +9,7 @@ vi.mock('../../context', () => ({ useEmbeddedChatbotContext: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: 'test-token' }), useRouter: () => ({ push: vi.fn() }), usePathname: () => '/', diff --git a/web/app/components/base/confirm/index.tsx b/web/app/components/base/confirm/index.tsx index 27b67ea507..91d9e7bfb8 100644 --- a/web/app/components/base/confirm/index.tsx +++ b/web/app/components/base/confirm/index.tsx @@ -26,6 +26,11 @@ export type IConfirm = { showConfirm?: boolean showCancel?: boolean maskClosable?: boolean + confirmInputLabel?: string + confirmInputPlaceholder?: string + confirmInputValue?: string + onConfirmInputChange?: (value: string) => void + confirmInputMatchValue?: string } function Confirm({ @@ -42,6 +47,11 @@ function Confirm({ isLoading = false, isDisabled = false, maskClosable = true, + confirmInputLabel, + confirmInputPlaceholder, + confirmInputValue = '', + onConfirmInputChange, + confirmInputMatchValue, }: IConfirm) { const { t } = useTranslation() const dialogRef = useRef(null) @@ -51,12 +61,13 @@ function Confirm({ const confirmTxt = confirmText || `${t('operation.confirm', { ns: 'common' })}` const cancelTxt = cancelText || `${t('operation.cancel', { ns: 'common' })}` + const isConfirmDisabled = isDisabled || (confirmInputMatchValue ? confirmInputValue !== confirmInputMatchValue : false) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onCancel() - if (event.key === 'Enter' && isShow) { + if (event.key === 'Enter' && isShow && !isConfirmDisabled) { event.preventDefault() onConfirm() } @@ -66,7 +77,7 @@ function Confirm({ return () => { document.removeEventListener('keydown', handleKeyDown) } - }, [onCancel, onConfirm, isShow]) + }, [onCancel, onConfirm, isShow, isConfirmDisabled]) const handleClickOutside = (event: MouseEvent) => { if (maskClosable && dialogRef.current && !dialogRef.current.contains(event.target as Node)) @@ -123,11 +134,25 @@ function Confirm({ {title}
-
{content}
+
{content}
+ {confirmInputLabel && ( +
+ + onConfirmInputChange?.(e.target.value)} + /> +
+ )}
{showCancel && } - {showConfirm && } + {showConfirm && }
diff --git a/web/app/components/base/encrypted-bottom/index.tsx b/web/app/components/base/encrypted-bottom/index.tsx index 5a9bc9b488..5f35433612 100644 --- a/web/app/components/base/encrypted-bottom/index.tsx +++ b/web/app/components/base/encrypted-bottom/index.tsx @@ -1,7 +1,7 @@ import type { I18nKeysWithPrefix } from '@/types/i18n' import { RiLock2Fill } from '@remixicon/react' -import Link from 'next/link' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import { cn } from '@/utils/classnames' type EncryptedKey = I18nKeysWithPrefix<'common', 'provider.encrypted.'> diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index 20632c4954..77f9a0253b 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { FeaturesProvider } from '../../context' import NewFeaturePanel from '../index' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), usePathname: () => '/app/test-app-id/configuration', })) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx index f2ddc5482d..03ddbc6322 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import AnnotationReply from '../index' const originalConsoleError = console.error const mockPush = vi.fn() let mockPathname = '/app/test-app-id/configuration' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), usePathname: () => mockPathname, })) diff --git a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx index df8982407c..1ad4ef613e 100644 --- a/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx +++ b/web/app/components/base/features/new-feature-panel/annotation-reply/index.tsx @@ -2,7 +2,6 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { AnnotationReplyConfig } from '@/models/debug' import { RiEqualizer2Line, RiExternalLinkLine } from '@remixicon/react' import { produce } from 'immer' -import { usePathname, useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -14,6 +13,7 @@ import FeatureCard from '@/app/components/base/features/new-feature-panel/featur import { MessageFast } from '@/app/components/base/icons/src/vender/features' import AnnotationFullModal from '@/app/components/billing/annotation-full/modal' import { ANNOTATION_DEFAULT } from '@/config' +import { usePathname, useRouter } from '@/next/navigation' type Props = { disabled?: boolean diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx index 66d870f28f..535d40e00a 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/param-config-content.spec.tsx @@ -22,7 +22,7 @@ const mockUseAppVoices = vi.fn((_appId: string, _language?: string) => ({ data: mockVoiceItems, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname, useParams: () => ({}), })) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx index 658d5f500b..f77802c133 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/__tests__/voice-settings.spec.tsx @@ -35,7 +35,7 @@ vi.mock('@/app/components/base/portal-to-follow-elem', () => ({ PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) =>
{children}
, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => '/app/test-app-id/configuration', useParams: () => ({ appId: 'test-app-id' }), })) diff --git a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx index 11db9346ff..d4e008c4e6 100644 --- a/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx +++ b/web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx @@ -3,7 +3,6 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { Item } from '@/app/components/base/select' import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react' import { produce } from 'immer' -import { usePathname } from 'next/navigation' import * as React from 'react' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' @@ -13,6 +12,7 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' import { languages } from '@/i18n-config/language' +import { usePathname } from '@/next/navigation' import { useAppVoices } from '@/service/use-apps' import { TtsAutoPlay } from '@/types/app' import { cn } from '@/utils/classnames' diff --git a/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx b/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx index cdae4a2e4f..868f153dbc 100644 --- a/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx +++ b/web/app/components/base/file-uploader/__tests__/dynamic-pdf-preview.spec.tsx @@ -40,7 +40,7 @@ const mockPdfPreview = vi.hoisted(() => vi.fn(() => null), ) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: mockDynamic, })) diff --git a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts index 8343974967..824a3b7a03 100644 --- a/web/app/components/base/file-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/file-uploader/__tests__/hooks.spec.ts @@ -6,7 +6,7 @@ import { useFile, useFileSizeLimit } from '../hooks' const mockNotify = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: undefined }), })) diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts index 4aab60175c..27345b22ff 100644 --- a/web/app/components/base/file-uploader/hooks.ts +++ b/web/app/components/base/file-uploader/hooks.ts @@ -4,7 +4,6 @@ import type { FileUpload } from '@/app/components/base/features/types' import type { FileUploadConfigResponse } from '@/models/common' import { noop } from 'es-toolkit/function' import { produce } from 'immer' -import { useParams } from 'next/navigation' import { useCallback, useState, @@ -20,6 +19,7 @@ import { } from '@/app/components/base/file-uploader/constants' import { useToastContext } from '@/app/components/base/toast/context' import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { useParams } from '@/next/navigation' import { uploadRemoteFileInfo } from '@/service/common' import { TransferMethod } from '@/types/app' import { formatFileSize } from '@/utils/format' diff --git a/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx b/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx index dee7c97222..bff8e9cbf9 100644 --- a/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx +++ b/web/app/components/base/form/components/field/__tests__/file-uploader.spec.tsx @@ -27,7 +27,7 @@ vi.mock('../../..', () => ({ useFieldContext: () => mockField, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: 'test-token' }), })) diff --git a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx index 1d7734f670..81190dc277 100644 --- a/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx +++ b/web/app/components/base/form/form-scenarios/base/__tests__/field.spec.tsx @@ -6,7 +6,7 @@ import { useAppForm } from '../../..' import BaseField from '../field' import { BaseFieldType } from '../types' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({}), })) diff --git a/web/app/components/base/ga/__tests__/index.spec.tsx b/web/app/components/base/ga/__tests__/index.spec.tsx index ee7f7a2a9d..619c4514dc 100644 --- a/web/app/components/base/ga/__tests__/index.spec.tsx +++ b/web/app/components/base/ga/__tests__/index.spec.tsx @@ -31,11 +31,11 @@ vi.mock('@/config', () => ({ }, })) -vi.mock('next/headers', () => ({ +vi.mock('@/next/headers', () => ({ headers: mockHeaders, })) -vi.mock('next/script', () => ({ +vi.mock('@/next/script', () => ({ default: ({ id, strategy, diff --git a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts index f79ea98081..e4295dfb09 100644 --- a/web/app/components/base/image-uploader/__tests__/hooks.spec.ts +++ b/web/app/components/base/image-uploader/__tests__/hooks.spec.ts @@ -9,7 +9,7 @@ vi.mock('@/app/components/base/toast/context', () => ({ useToastContext: () => ({ notify: mockNotify }), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ token: undefined }), })) diff --git a/web/app/components/base/image-uploader/hooks.ts b/web/app/components/base/image-uploader/hooks.ts index 03cf0feeca..9251d3888f 100644 --- a/web/app/components/base/image-uploader/hooks.ts +++ b/web/app/components/base/image-uploader/hooks.ts @@ -1,9 +1,9 @@ import type { ClipboardEvent } from 'react' import type { ImageFile, VisionSettings } from '@/types/app' -import { useParams } from 'next/navigation' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useToastContext } from '@/app/components/base/toast/context' +import { useParams } from '@/next/navigation' import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app' import { getImageUploadErrorMessage, imageUpload } from './utils' diff --git a/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx b/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx index 27408531c4..5576fb289e 100644 --- a/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/linked-apps-panel/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import { vi } from 'vitest' import { AppModeEnum } from '@/types/app' import LinkedAppsPanel from '../index' -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, className }: { children: React.ReactNode, href: string, className: string }) => ( {children} diff --git a/web/app/components/base/linked-apps-panel/index.tsx b/web/app/components/base/linked-apps-panel/index.tsx index adc8ccf729..1ce76e0647 100644 --- a/web/app/components/base/linked-apps-panel/index.tsx +++ b/web/app/components/base/linked-apps-panel/index.tsx @@ -2,9 +2,9 @@ import type { FC } from 'react' import type { RelatedApp } from '@/models/datasets' import { RiArrowRightUpLine } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import AppIcon from '@/app/components/base/app-icon' +import Link from '@/next/link' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' diff --git a/web/app/components/base/markdown/__tests__/index.spec.tsx b/web/app/components/base/markdown/__tests__/index.spec.tsx index 5d0261b074..08c4527003 100644 --- a/web/app/components/base/markdown/__tests__/index.spec.tsx +++ b/web/app/components/base/markdown/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ const { mockReactMarkdownWrapper } = vi.hoisted(() => ({ mockReactMarkdownWrapper: vi.fn(), })) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: () => { const MockStreamdownWrapper = (props: { latexContent: string }) => { mockReactMarkdownWrapper(props) diff --git a/web/app/components/base/new-audio-button/__tests__/index.spec.tsx b/web/app/components/base/new-audio-button/__tests__/index.spec.tsx index 64dd590012..23696fca74 100644 --- a/web/app/components/base/new-audio-button/__tests__/index.spec.tsx +++ b/web/app/components/base/new-audio-button/__tests__/index.spec.tsx @@ -1,15 +1,15 @@ import { act, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import i18next from 'i18next' -import { useParams, usePathname } from 'next/navigation' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { useParams, usePathname } from '@/next/navigation' import AudioBtn from '../index' const mockPlayAudio = vi.fn() const mockPauseAudio = vi.fn() const mockGetAudioPlayer = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(), usePathname: vi.fn(), })) diff --git a/web/app/components/base/new-audio-button/index.tsx b/web/app/components/base/new-audio-button/index.tsx index 7e1e1ccc78..c6569ff958 100644 --- a/web/app/components/base/new-audio-button/index.tsx +++ b/web/app/components/base/new-audio-button/index.tsx @@ -3,11 +3,11 @@ import { RiVolumeUpLine, } from '@remixicon/react' import { t } from 'i18next' -import { useParams, usePathname } from 'next/navigation' import { useState } from 'react' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import Tooltip from '@/app/components/base/tooltip' +import { useParams, usePathname } from '@/next/navigation' type AudioBtnProps = { id?: string diff --git a/web/app/components/base/toast/context.ts b/web/app/components/base/toast/context.ts index ddd8f91336..07b4e72602 100644 --- a/web/app/components/base/toast/context.ts +++ b/web/app/components/base/toast/context.ts @@ -1,8 +1,15 @@ 'use client' +/** + * @deprecated Use `@/app/components/base/ui/toast` instead. + * This module will be removed after migration is complete. + * See: https://github.com/langgenius/dify/issues/32811 + */ + import type { ReactNode } from 'react' import { createContext, useContext } from 'use-context-selector' +/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */ export type IToastProps = { type?: 'success' | 'error' | 'warning' | 'info' size?: 'md' | 'sm' @@ -19,5 +26,8 @@ type IToastContext = { close: () => void } +/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */ export const ToastContext = createContext({} as IToastContext) + +/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */ export const useToastContext = () => useContext(ToastContext) diff --git a/web/app/components/base/toast/index.tsx b/web/app/components/base/toast/index.tsx index 897b6039ba..0cb14f3f11 100644 --- a/web/app/components/base/toast/index.tsx +++ b/web/app/components/base/toast/index.tsx @@ -1,4 +1,11 @@ 'use client' + +/** + * @deprecated Use `@/app/components/base/ui/toast` instead. + * This component will be removed after migration is complete. + * See: https://github.com/langgenius/dify/issues/32811 + */ + import type { ReactNode } from 'react' import type { IToastProps } from './context' import { noop } from 'es-toolkit/function' @@ -12,6 +19,7 @@ import { ToastContext, useToastContext } from './context' export type ToastHandle = { clear?: VoidFunction } + const Toast = ({ type = 'info', size = 'md', @@ -74,6 +82,7 @@ const Toast = ({ ) } +/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */ export const ToastProvider = ({ children, }: { diff --git a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx index c5fb532d98..b6772e5ad0 100644 --- a/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/dropdown-menu/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import type { ComponentPropsWithoutRef, ReactNode } from 'react' import { fireEvent, render, screen, within } from '@testing-library/react' -import Link from 'next/link' import { describe, expect, it, vi } from 'vitest' +import Link from '@/next/link' import { DropdownMenu, DropdownMenuContent, @@ -14,7 +14,7 @@ import { DropdownMenuTrigger, } from '../index' -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ href, children, diff --git a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx new file mode 100644 index 0000000000..e506fe59d0 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx @@ -0,0 +1,263 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaCorner, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '../index' +import styles from '../index.module.css' + +const renderScrollArea = (options: { + rootClassName?: string + viewportClassName?: string + verticalScrollbarClassName?: string + horizontalScrollbarClassName?: string + verticalThumbClassName?: string + horizontalThumbClassName?: string +} = {}) => { + return render( + + + +
Scrollable content
+
+
+ + + + + + +
, + ) +} + +describe('scroll-area wrapper', () => { + describe('Rendering', () => { + it('should render the compound exports together', async () => { + renderScrollArea() + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-root')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-viewport')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-content')).toHaveTextContent('Scrollable content') + expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-vertical-thumb')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument() + }) + }) + }) + + describe('Scrollbar', () => { + it('should apply the default vertical scrollbar classes and orientation data attribute', async () => { + renderScrollArea() + + await waitFor(() => { + const scrollbar = screen.getByTestId('scroll-area-vertical-scrollbar') + const thumb = screen.getByTestId('scroll-area-vertical-thumb') + + expect(scrollbar).toHaveAttribute('data-orientation', 'vertical') + expect(scrollbar).toHaveClass(styles.scrollbar) + expect(scrollbar).toHaveClass( + 'flex', + 'overflow-clip', + 'p-1', + 'touch-none', + 'select-none', + 'opacity-100', + 'transition-opacity', + 'motion-reduce:transition-none', + 'pointer-events-none', + 'data-[hovering]:pointer-events-auto', + 'data-[scrolling]:pointer-events-auto', + 'data-[orientation=vertical]:absolute', + 'data-[orientation=vertical]:inset-y-0', + 'data-[orientation=vertical]:w-3', + 'data-[orientation=vertical]:justify-center', + ) + expect(thumb).toHaveAttribute('data-orientation', 'vertical') + expect(thumb).toHaveClass( + 'shrink-0', + 'rounded-[4px]', + 'bg-state-base-handle', + 'transition-[background-color]', + 'motion-reduce:transition-none', + 'data-[orientation=vertical]:w-1', + ) + }) + }) + + it('should apply horizontal scrollbar and thumb classes when orientation is horizontal', async () => { + renderScrollArea() + + await waitFor(() => { + const scrollbar = screen.getByTestId('scroll-area-horizontal-scrollbar') + const thumb = screen.getByTestId('scroll-area-horizontal-thumb') + + expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal') + expect(scrollbar).toHaveClass(styles.scrollbar) + expect(scrollbar).toHaveClass( + 'flex', + 'overflow-clip', + 'p-1', + 'touch-none', + 'select-none', + 'opacity-100', + 'transition-opacity', + 'motion-reduce:transition-none', + 'pointer-events-none', + 'data-[hovering]:pointer-events-auto', + 'data-[scrolling]:pointer-events-auto', + 'data-[orientation=horizontal]:absolute', + 'data-[orientation=horizontal]:inset-x-0', + 'data-[orientation=horizontal]:h-3', + 'data-[orientation=horizontal]:items-center', + ) + expect(thumb).toHaveAttribute('data-orientation', 'horizontal') + expect(thumb).toHaveClass( + 'shrink-0', + 'rounded-[4px]', + 'bg-state-base-handle', + 'transition-[background-color]', + 'motion-reduce:transition-none', + 'data-[orientation=horizontal]:h-1', + ) + }) + }) + }) + + describe('Props', () => { + it('should forward className to the viewport', async () => { + renderScrollArea({ + viewportClassName: 'custom-viewport-class', + }) + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-viewport')).toHaveClass( + 'size-full', + 'min-h-0', + 'min-w-0', + 'outline-none', + 'focus-visible:ring-1', + 'focus-visible:ring-inset', + 'focus-visible:ring-components-input-border-hover', + 'custom-viewport-class', + ) + }) + }) + + it('should let callers control scrollbar inset spacing via margin-based className overrides', async () => { + renderScrollArea({ + verticalScrollbarClassName: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]', + horizontalScrollbarClassName: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-2', + }) + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toHaveClass( + 'data-[orientation=vertical]:my-2', + 'data-[orientation=vertical]:[margin-inline-end:-0.75rem]', + ) + expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toHaveClass( + 'data-[orientation=horizontal]:mx-2', + 'data-[orientation=horizontal]:mb-2', + ) + }) + }) + }) + + describe('Corner', () => { + it('should render the corner export when both axes overflow', async () => { + const originalDescriptors = { + clientHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight'), + clientWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientWidth'), + scrollHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollHeight'), + scrollWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollWidth'), + } + + Object.defineProperties(HTMLDivElement.prototype, { + clientHeight: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0 + }, + }, + clientWidth: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0 + }, + }, + scrollHeight: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0 + }, + }, + scrollWidth: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0 + }, + }, + }) + + try { + render( + + + +
Scrollable content
+
+
+ + + + + + + +
, + ) + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-corner')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-corner')).toHaveClass('bg-transparent') + }) + } + finally { + if (originalDescriptors.clientHeight) { + Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalDescriptors.clientHeight) + } + if (originalDescriptors.clientWidth) { + Object.defineProperty(HTMLDivElement.prototype, 'clientWidth', originalDescriptors.clientWidth) + } + if (originalDescriptors.scrollHeight) { + Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', originalDescriptors.scrollHeight) + } + if (originalDescriptors.scrollWidth) { + Object.defineProperty(HTMLDivElement.prototype, 'scrollWidth', originalDescriptors.scrollWidth) + } + } + }) + }) +}) diff --git a/web/app/components/base/ui/scroll-area/index.module.css b/web/app/components/base/ui/scroll-area/index.module.css new file mode 100644 index 0000000000..a81fd3d3c2 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/index.module.css @@ -0,0 +1,75 @@ +.scrollbar::before, +.scrollbar::after { + content: ''; + position: absolute; + z-index: 1; + border-radius: 9999px; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; +} + +.scrollbar[data-orientation='vertical']::before { + left: 50%; + top: 4px; + width: 4px; + height: 12px; + transform: translateX(-50%); + background: linear-gradient(to bottom, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); +} + +.scrollbar[data-orientation='vertical']::after { + left: 50%; + bottom: 4px; + width: 4px; + height: 12px; + transform: translateX(-50%); + background: linear-gradient(to top, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); +} + +.scrollbar[data-orientation='horizontal']::before { + top: 50%; + left: 4px; + width: 12px; + height: 4px; + transform: translateY(-50%); + background: linear-gradient(to right, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); +} + +.scrollbar[data-orientation='horizontal']::after { + top: 50%; + right: 4px; + width: 12px; + height: 4px; + transform: translateY(-50%); + background: linear-gradient(to left, var(--scroll-area-edge-hint-bg, var(--color-components-panel-bg)), transparent); +} + +.scrollbar[data-orientation='vertical']:not([data-overflow-y-start])::before { + opacity: 1; +} + +.scrollbar[data-orientation='vertical']:not([data-overflow-y-end])::after { + opacity: 1; +} + +.scrollbar[data-orientation='horizontal']:not([data-overflow-x-start])::before { + opacity: 1; +} + +.scrollbar[data-orientation='horizontal']:not([data-overflow-x-end])::after { + opacity: 1; +} + +.scrollbar[data-hovering] > [data-orientation], +.scrollbar[data-scrolling] > [data-orientation], +.scrollbar > [data-orientation]:active { + background-color: var(--scroll-area-thumb-bg-active, var(--color-state-base-handle-hover)); +} + +@media (prefers-reduced-motion: reduce) { + .scrollbar::before, + .scrollbar::after { + transition: none; + } +} diff --git a/web/app/components/base/ui/scroll-area/index.stories.tsx b/web/app/components/base/ui/scroll-area/index.stories.tsx new file mode 100644 index 0000000000..465e534921 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/index.stories.tsx @@ -0,0 +1,712 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { ReactNode } from 'react' +import * as React from 'react' +import AppIcon from '@/app/components/base/app-icon' +import { cn } from '@/utils/classnames' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaCorner, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '.' + +const meta = { + title: 'Base/Layout/ScrollArea', + component: ScrollArea, + parameters: { + layout: 'padded', + docs: { + description: { + component: 'Compound scroll container built on Base UI ScrollArea. These stories focus on panel-style compositions that already exist throughout Dify: dense sidebars, sticky list headers, multi-pane workbenches, horizontal rails, and overlay surfaces. Scrollbar placement should be adjusted by consumer spacing classes such as margin-based overrides instead of right/bottom positioning utilities.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const panelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5' +const blurPanelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl shadow-shadow-shadow-7 backdrop-blur-[6px]' +const labelClassName = 'text-text-tertiary system-xs-medium-uppercase tracking-[0.14em]' +const titleClassName = 'text-text-primary system-sm-semibold' +const bodyClassName = 'text-text-secondary system-sm-regular' +const insetScrollAreaClassName = 'h-full p-1' +const insetViewportClassName = 'rounded-[20px] bg-components-panel-bg' +const insetScrollbarClassName = 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:[margin-inline-end:0.25rem] data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1' +const storyButtonClassName = 'flex w-full items-center justify-between gap-3 rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-2.5 text-left text-text-secondary transition-colors hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none' +const sidebarScrollAreaClassName = 'h-full' +const sidebarViewportClassName = 'overscroll-contain' +const sidebarContentClassName = 'space-y-0.5' +const sidebarScrollbarClassName = 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]' +const appNavButtonClassName = 'group flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none' +const appNavMetaClassName = 'shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 text-text-quaternary system-2xs-medium-uppercase tracking-[0.08em]' + +const releaseRows = [ + { title: 'Agent refactor', meta: 'Updated 2 hours ago', status: 'Ready' }, + { title: 'Retriever tuning', meta: 'Updated yesterday', status: 'Review' }, + { title: 'Workflow replay', meta: 'Updated 3 days ago', status: 'Draft' }, + { title: 'Sandbox policy', meta: 'Updated this week', status: 'Ready' }, + { title: 'SSE diagnostics', meta: 'Updated last week', status: 'Blocked' }, + { title: 'Model routing', meta: 'Updated 9 days ago', status: 'Review' }, + { title: 'Chunk overlap', meta: 'Updated 11 days ago', status: 'Draft' }, + { title: 'Vector warmup', meta: 'Updated 2 weeks ago', status: 'Ready' }, +] as const + +const queueRows = [ + { id: 'PLG-142', title: 'Plugin catalog sync', note: 'Waiting for moderation result' }, + { id: 'OPS-088', title: 'Billing alert fallback', note: 'Last retry finished 12 minutes ago' }, + { id: 'RAG-511', title: 'Embedding migration', note: '16 datasets still pending' }, + { id: 'AGT-204', title: 'Multi-agent tracing', note: 'QA is verifying edge cases' }, + { id: 'UI-390', title: 'Prompt editor polish', note: 'Needs token density pass' }, + { id: 'WEB-072', title: 'Marketplace empty state', note: 'Waiting for design review' }, +] as const + +const horizontalCards = [ + { title: 'Claude Opus', detail: 'Reasoning-heavy preset' }, + { title: 'GPT-5.4', detail: 'Balanced orchestration lane' }, + { title: 'Gemini 2.5', detail: 'Multimodal fallback' }, + { title: 'Qwen Max', detail: 'Regional deployment' }, + { title: 'DeepSeek R1', detail: 'High-throughput analysis' }, + { title: 'Llama 4', detail: 'Cost-sensitive routing' }, +] as const + +const activityRows = Array.from({ length: 14 }, (_, index) => ({ + title: `Workspace activity ${index + 1}`, + body: 'A short line of copy to mimic dense operational feeds in settings and debug panels.', +})) + +const scrollbarShowcaseRows = Array.from({ length: 18 }, (_, index) => ({ + title: `Scroll checkpoint ${index + 1}`, + body: 'Dedicated story content so the scrollbar can be inspected without sticky headers, masks, or clipped shells.', +})) + +const horizontalShowcaseCards = Array.from({ length: 8 }, (_, index) => ({ + title: `Lane ${index + 1}`, + body: 'Horizontal scrollbar reference without edge hints.', +})) + +const webAppsRows = [ + { id: 'invoice-copilot', name: 'Invoice Copilot', meta: 'Pinned', icon: '🧾', iconBackground: '#FFEAD5', selected: true, pinned: true }, + { id: 'rag-ops', name: 'RAG Ops Console', meta: 'Ops', icon: '🛰️', iconBackground: '#E0F2FE', selected: false, pinned: true }, + { id: 'knowledge-studio', name: 'Knowledge Studio', meta: 'Docs', icon: '📚', iconBackground: '#FEF3C7', selected: false, pinned: true }, + { id: 'workflow-studio', name: 'Workflow Studio', meta: 'Build', icon: '🧩', iconBackground: '#E0E7FF', selected: false, pinned: true }, + { id: 'growth-briefs', name: 'Growth Briefs', meta: 'Brief', icon: '📣', iconBackground: '#FCE7F3', selected: false, pinned: true }, + { id: 'agent-playground', name: 'Agent Playground', meta: 'Lab', icon: '🧪', iconBackground: '#DCFCE7', selected: false, pinned: false }, + { id: 'sales-briefing', name: 'Sales Briefing', meta: 'Team', icon: '📈', iconBackground: '#FCE7F3', selected: false, pinned: false }, + { id: 'support-triage', name: 'Support Triage', meta: 'Queue', icon: '🎧', iconBackground: '#EDE9FE', selected: false, pinned: false }, + { id: 'legal-review', name: 'Legal Review', meta: 'Beta', icon: '⚖️', iconBackground: '#FDE68A', selected: false, pinned: false }, + { id: 'release-watcher', name: 'Release Watcher', meta: 'Feed', icon: '🚀', iconBackground: '#DBEAFE', selected: false, pinned: false }, + { id: 'research-hub', name: 'Research Hub', meta: 'Notes', icon: '🔎', iconBackground: '#E0F2FE', selected: false, pinned: false }, + { id: 'field-enablement', name: 'Field Enablement', meta: 'Team', icon: '🧭', iconBackground: '#DCFCE7', selected: false, pinned: false }, + { id: 'brand-monitor', name: 'Brand Monitor', meta: 'Watch', icon: '🪄', iconBackground: '#F3E8FF', selected: false, pinned: false }, + { id: 'finance-ops', name: 'Finance Ops Desk', meta: 'Ops', icon: '💳', iconBackground: '#FEF3C7', selected: false, pinned: false }, + { id: 'security-radar', name: 'Security Radar', meta: 'Risk', icon: '🛡️', iconBackground: '#FEE2E2', selected: false, pinned: false }, + { id: 'partner-portal', name: 'Partner Portal', meta: 'Ext', icon: '🤝', iconBackground: '#DBEAFE', selected: false, pinned: false }, + { id: 'qa-replays', name: 'QA Replays', meta: 'Debug', icon: '🎞️', iconBackground: '#EDE9FE', selected: false, pinned: false }, + { id: 'roadmap-notes', name: 'Roadmap Notes', meta: 'Plan', icon: '🗺️', iconBackground: '#FFEAD5', selected: false, pinned: false }, +] as const + +const StoryCard = ({ + eyebrow, + title, + description, + className, + children, +}: { + eyebrow: string + title: string + description: string + className?: string + children: ReactNode +}) => ( +
+
+
{eyebrow}
+

{title}

+

{description}

+
+ {children} +
+) + +const VerticalPanelPane = () => ( +
+ + + +
+
Release board
+
Weekly checkpoints
+

A simple vertical panel with the default scrollbar skin and no business-specific overrides.

+
+ {releaseRows.map(item => ( +
+
+
+

{item.title}

+

{item.meta}

+
+ + {item.status} + +
+
+ ))} +
+
+ + + +
+
+) + +const StickyListPane = () => ( +
+ + + +
+
Sticky header
+
+
+
Operational queue
+

The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.

+
+ + 24 items + +
+
+
+ {queueRows.map(item => ( +
+
+
+
{item.title}
+
{item.note}
+
+ {item.id} +
+
+ ))} +
+
+
+ + + +
+
+) + +const WorkbenchPane = ({ + title, + eyebrow, + children, + className, +}: { + title: string + eyebrow: string + children: ReactNode + className?: string +}) => ( +
+ + + +
+
{eyebrow}
+
{title}
+
+ {children} +
+
+ + + +
+
+) + +const HorizontalRailPane = () => ( +
+ + + +
+
Horizontal rail
+
Model lanes
+

This pane keeps the default track behavior and only changes the surface layout around it.

+
+
+ {horizontalCards.map(card => ( +
+
+ + + +
{card.title}
+
{card.detail}
+
+
Drag cards into orchestration groups.
+
+ ))} +
+
+
+ + + +
+
+) + +const ScrollbarStatePane = ({ + eyebrow, + title, + description, + initialPosition, +}: { + eyebrow: string + title: string + description: string + initialPosition: 'top' | 'middle' | 'bottom' +}) => { + const viewportId = React.useId() + + React.useEffect(() => { + let frameA = 0 + let frameB = 0 + + const syncScrollPosition = () => { + const viewport = document.getElementById(viewportId) + + if (!(viewport instanceof HTMLDivElement)) + return + + const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight) + + if (initialPosition === 'top') + viewport.scrollTop = 0 + + if (initialPosition === 'middle') + viewport.scrollTop = maxScrollTop / 2 + + if (initialPosition === 'bottom') + viewport.scrollTop = maxScrollTop + } + + frameA = requestAnimationFrame(() => { + frameB = requestAnimationFrame(syncScrollPosition) + }) + + return () => { + cancelAnimationFrame(frameA) + cancelAnimationFrame(frameB) + } + }, [initialPosition, viewportId]) + + return ( +
+
+
{eyebrow}
+
{title}
+

{description}

+
+
+ + + + {scrollbarShowcaseRows.map(item => ( +
+
{item.title}
+
{item.body}
+
+ ))} +
+
+ + + +
+
+
+ ) +} + +const HorizontalScrollbarShowcasePane = () => ( +
+
+
Horizontal
+
Horizontal track reference
+

Current design delivery defines the horizontal scrollbar body, but not a horizontal edge hint.

+
+
+ + + +
+
Horizontal scrollbar
+
A clean horizontal pane to inspect thickness, padding, and thumb behavior without extra masks.
+
+
+ {horizontalShowcaseCards.map(card => ( +
+
{card.title}
+
{card.body}
+
+ ))} +
+
+
+ + + +
+
+
+) + +const OverlayPane = () => ( +
+
+ + + +
+
Overlay palette
+
Quick actions
+
+ {activityRows.map(item => ( +
+
+ + + +
+
{item.title}
+
{item.body}
+
+
+
+ ))} +
+
+ + + +
+
+
+) + +const CornerPane = () => ( +
+ + + +
+
+
Corner surface
+
Bi-directional inspector canvas
+

Both axes overflow here so the corner becomes visible as a deliberate seam between the two tracks.

+
+ + Always visible + +
+
+ {Array.from({ length: 12 }, (_, index) => ( +
+
+ Cell + {' '} + {index + 1} +
+

+ Wide-and-tall content to force both scrollbars and show the corner treatment clearly. +

+
+ ))} +
+
+
+ + + + + + + +
+
+) + +const ExploreSidebarWebAppsPane = () => { + const pinnedAppsCount = webAppsRows.filter(item => item.pinned).length + + return ( +
+
+
+
+
+ +
+
+ Explore +
+
+
+ +
+
+

+ Web Apps +

+ + {webAppsRows.length} + +
+ +
+ + + + {webAppsRows.map((item, index) => ( +
+ + {index === pinnedAppsCount - 1 && index !== webAppsRows.length - 1 && ( +
+ )} +
+ ))} + + + + + + +
+
+
+
+ ) +} + +export const VerticalPanels: Story = { + render: () => ( + +
+ + +
+
+ ), +} + +export const ThreePaneWorkbench: Story = { + render: () => ( + +
+ +
+ {releaseRows.map(item => ( + + ))} +
+
+ +
+ {Array.from({ length: 7 }, (_, index) => ( +
+
+
+ Section + {' '} + {index + 1} +
+ + Active + +
+

+ This pane is intentionally long so the default vertical scrollbar sits over a larger editorial surface. +

+
+ ))} +
+
+ +
+ {queueRows.map(item => ( +
+
{item.id}
+
{item.title}
+
{item.note}
+
+ ))} +
+
+
+
+ ), +} + +export const HorizontalAndOverlay: Story = { + render: () => ( +
+ + + + + + +
+ ), +} + +export const CornerSurface: Story = { + render: () => ( + +
+ +
+
+ ), +} + +export const ExploreSidebarWebApps: Story = { + render: () => ( + +
+ +
+
+ ), +} + +export const PrimitiveComposition: Story = { + render: () => ( + +
+ + + + {Array.from({ length: 8 }, (_, index) => ( +
+ Primitive row + {' '} + {index + 1} +
+ ))} +
+
+ + + + + + + +
+
+
+ ), +} + +export const ScrollbarDelivery: Story = { + render: () => ( + +
+ + + + +
+
+ ), +} diff --git a/web/app/components/base/ui/scroll-area/index.tsx b/web/app/components/base/ui/scroll-area/index.tsx new file mode 100644 index 0000000000..840cb86021 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/index.tsx @@ -0,0 +1,90 @@ +'use client' + +import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area' +import * as React from 'react' +import { cn } from '@/utils/classnames' +import styles from './index.module.css' + +export const ScrollArea = BaseScrollArea.Root +export type ScrollAreaRootProps = React.ComponentPropsWithRef + +export const ScrollAreaContent = BaseScrollArea.Content +export type ScrollAreaContentProps = React.ComponentPropsWithRef + +export const scrollAreaScrollbarClassName = cn( + styles.scrollbar, + 'flex touch-none select-none overflow-clip p-1 opacity-100 transition-opacity motion-reduce:transition-none', + 'pointer-events-none data-[hovering]:pointer-events-auto', + 'data-[scrolling]:pointer-events-auto', + 'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center', + 'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center', +) + +export const scrollAreaThumbClassName = cn( + 'shrink-0 rounded-[4px] bg-state-base-handle transition-[background-color] motion-reduce:transition-none', + 'data-[orientation=vertical]:w-1', + 'data-[orientation=horizontal]:h-1', +) + +export const scrollAreaViewportClassName = cn( + 'size-full min-h-0 min-w-0 outline-none', + 'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover', +) + +export const scrollAreaCornerClassName = 'bg-transparent' + +export type ScrollAreaViewportProps = React.ComponentPropsWithRef + +export function ScrollAreaViewport({ + className, + ...props +}: ScrollAreaViewportProps) { + return ( + + ) +} + +export type ScrollAreaScrollbarProps = React.ComponentPropsWithRef + +export function ScrollAreaScrollbar({ + className, + ...props +}: ScrollAreaScrollbarProps) { + return ( + + ) +} + +export type ScrollAreaThumbProps = React.ComponentPropsWithRef + +export function ScrollAreaThumb({ + className, + ...props +}: ScrollAreaThumbProps) { + return ( + + ) +} + +export type ScrollAreaCornerProps = React.ComponentPropsWithRef + +export function ScrollAreaCorner({ + className, + ...props +}: ScrollAreaCornerProps) { + return ( + + ) +} diff --git a/web/app/components/base/voice-input/__tests__/index.spec.tsx b/web/app/components/base/voice-input/__tests__/index.spec.tsx index ac9c367e6a..e252c42f84 100644 --- a/web/app/components/base/voice-input/__tests__/index.spec.tsx +++ b/web/app/components/base/voice-input/__tests__/index.spec.tsx @@ -47,7 +47,7 @@ vi.mock('@/service/share', () => ({ audioToText: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(() => mockState.params), usePathname: vi.fn(() => mockState.pathname), })) diff --git a/web/app/components/base/voice-input/index.tsx b/web/app/components/base/voice-input/index.tsx index 8e26bbc895..9ae390a3ca 100644 --- a/web/app/components/base/voice-input/index.tsx +++ b/web/app/components/base/voice-input/index.tsx @@ -1,8 +1,8 @@ import { useRafInterval } from 'ahooks' import Recorder from 'js-audio-recorder' -import { useParams, usePathname } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useParams, usePathname } from '@/next/navigation' import { AppSourceType, audioToText } from '@/service/share' import { cn } from '@/utils/classnames' import s from './index.module.css' diff --git a/web/app/components/base/zendesk/__tests__/index.spec.tsx b/web/app/components/base/zendesk/__tests__/index.spec.tsx index 4ab84a0088..e928b1437b 100644 --- a/web/app/components/base/zendesk/__tests__/index.spec.tsx +++ b/web/app/components/base/zendesk/__tests__/index.spec.tsx @@ -26,7 +26,7 @@ vi.mock('@/config', () => ({ })) // Mock next/headers -vi.mock('next/headers', () => ({ +vi.mock('@/next/headers', () => ({ headers: vi.fn(() => ({ get: vi.fn((name: string) => { if (name === 'x-nonce') @@ -44,7 +44,7 @@ type ScriptProps = { 'nonce'?: string 'data-testid'?: string } -vi.mock('next/script', () => ({ +vi.mock('@/next/script', () => ({ __esModule: true, default: vi.fn(({ children, id, src, nonce, 'data-testid': testId }: ScriptProps) => (
diff --git a/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx index ec79d18d29..2ea5db840f 100644 --- a/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx +++ b/web/app/components/billing/partner-stack/__tests__/use-ps-info.spec.tsx @@ -48,7 +48,7 @@ vi.mock('js-cookie', () => { remove, } }) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSearchParams: () => ({ get: (key: string) => searchParamsValues[key] ?? null, }), diff --git a/web/app/components/billing/partner-stack/use-ps-info.ts b/web/app/components/billing/partner-stack/use-ps-info.ts index 51d693f358..7c45d7ef87 100644 --- a/web/app/components/billing/partner-stack/use-ps-info.ts +++ b/web/app/components/billing/partner-stack/use-ps-info.ts @@ -1,8 +1,8 @@ import { useBoolean } from 'ahooks' import Cookies from 'js-cookie' -import { useSearchParams } from 'next/navigation' import { useCallback } from 'react' import { PARTNER_STACK_CONFIG } from '@/config' +import { useSearchParams } from '@/next/navigation' import { useBindPartnerStackInfo } from '@/service/use-billing' const usePSInfo = () => { diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx index 79597b4b22..bed7ebd9fb 100644 --- a/web/app/components/billing/plan/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ let currentPath = '/billing' const push = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push }), usePathname: () => currentPath, })) diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 2f953c3a8e..b420110a4d 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -7,7 +7,6 @@ import { RiGroupLine, } from '@remixicon/react' import { useUnmountedRef } from 'ahooks' -import { usePathname, useRouter } from 'next/navigation' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -19,6 +18,7 @@ import VerifyStateModal from '@/app/education-apply/verify-state-modal' import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { usePathname, useRouter } from '@/next/navigation' import { useEducationVerify } from '@/service/use-education' import { getDaysUntilEndOfMonth } from '@/utils/time' import { Loading } from '../../base/icons/src/public/thought' diff --git a/web/app/components/billing/pricing/__tests__/footer.spec.tsx b/web/app/components/billing/pricing/__tests__/footer.spec.tsx index 762d0ad211..9a9215c177 100644 --- a/web/app/components/billing/pricing/__tests__/footer.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/footer.spec.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import Footer from '../footer' import { CategoryEnum } from '../types' -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( {children} diff --git a/web/app/components/billing/pricing/__tests__/index.spec.tsx b/web/app/components/billing/pricing/__tests__/index.spec.tsx index 1be2234cf9..36848cd463 100644 --- a/web/app/components/billing/pricing/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ vi.mock('../plans/self-hosted-plan-item/list', () => ({ ), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, className, target }: { children: React.ReactNode, href: string, className?: string, target?: string }) => ( {children} diff --git a/web/app/components/billing/pricing/footer.tsx b/web/app/components/billing/pricing/footer.tsx index 6a213eca00..0d3fd965b0 100644 --- a/web/app/components/billing/pricing/footer.tsx +++ b/web/app/components/billing/pricing/footer.tsx @@ -1,7 +1,7 @@ import type { Category } from './types' -import Link from 'next/link' import * as React from 'react' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { CategoryEnum } from './types' diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx index 1c7283abeb..bd602df6c1 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx @@ -1,22 +1,16 @@ import type { Mock } from 'vitest' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { toast, ToastHost } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { fetchSubscriptionUrls } from '@/service/billing' import { consoleClient } from '@/service/client' -import Toast from '../../../../../base/toast' import { ALL_PLANS } from '../../../../config' import { Plan } from '../../../../type' import { PlanRange } from '../../../plan-switcher/plan-range-switcher' import CloudPlanItem from '../index' -vi.mock('../../../../../base/toast', () => ({ - default: { - notify: vi.fn(), - }, -})) - vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) @@ -47,11 +41,19 @@ const mockUseAppContext = useAppContext as Mock const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock const mockBillingInvoices = consoleClient.billing.invoices as Mock const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock -const mockToastNotify = Toast.notify as Mock let assignedHref = '' const originalLocation = window.location +const renderWithToastHost = (ui: React.ReactNode) => { + return render( + <> + + {ui} + , + ) +} + beforeAll(() => { Object.defineProperty(window, 'location', { configurable: true, @@ -68,6 +70,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks() + toast.close() mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open())) mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' }) @@ -163,7 +166,7 @@ describe('CloudPlanItem', () => { it('should show toast when non-manager tries to buy a plan', () => { mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false }) - render( + renderWithToastHost( { ) fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'billing.buyPermissionDeniedTip', - })) + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() expect(mockBillingInvoices).not.toHaveBeenCalled() }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 0807381bcd..56856ccb77 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -4,11 +4,11 @@ import type { BasicPlan } from '../../../type' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { toast } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { fetchSubscriptionUrls } from '@/service/billing' import { consoleClient } from '@/service/client' -import Toast from '../../../../base/toast' import { ALL_PLANS } from '../../../config' import { Plan } from '../../../type' import { Professional, Sandbox, Team } from '../../assets' @@ -66,10 +66,9 @@ const CloudPlanItem: FC = ({ return if (!isCurrentWorkspaceManager) { - Toast.notify({ + toast.add({ type: 'error', - message: t('buyPermissionDeniedTip', { ns: 'billing' }), - className: 'z-[1001]', + title: t('buyPermissionDeniedTip', { ns: 'billing' }), }) return } @@ -83,7 +82,7 @@ const CloudPlanItem: FC = ({ throw new Error('Failed to open billing page') }, { onError: (err) => { - Toast.notify({ type: 'error', message: err.message || String(err) }) + toast.add({ type: 'error', title: err.message || String(err) }) }, }) return @@ -111,34 +110,34 @@ const CloudPlanItem: FC = ({ { isMostPopularPlan && (
- + {t('plansCommon.mostPopular', { ns: 'billing' })}
) }
-
{t(`${i18nPrefix}.description`, { ns: 'billing' })}
+
{t(`${i18nPrefix}.description`, { ns: 'billing' })}
{/* Price */}
{isFreePlan && ( - {t('plansCommon.free', { ns: 'billing' })} + {t('plansCommon.free', { ns: 'billing' })} )} {!isFreePlan && ( <> {isYear && ( - + $ {planInfo.price * 12} )} - + $ {isYear ? planInfo.price * 10 : planInfo.price} - + {t('plansCommon.priceTip', { ns: 'billing' })} {t(`plansCommon.${!isYear ? 'month' : 'year'}`, { ns: 'billing' })} diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx index 9507cdef3c..d086b6ed9a 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx @@ -1,8 +1,8 @@ import type { Mock } from 'vitest' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' +import { toast, ToastHost } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' -import Toast from '../../../../../base/toast' import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config' import { SelfHostedPlan } from '../../../../type' import SelfHostedPlanItem from '../index' @@ -16,12 +16,6 @@ vi.mock('../list', () => ({ ), })) -vi.mock('../../../../../base/toast', () => ({ - default: { - notify: vi.fn(), - }, -})) - vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) @@ -35,11 +29,19 @@ vi.mock('../../../assets', () => ({ })) const mockUseAppContext = useAppContext as Mock -const mockToastNotify = Toast.notify as Mock let assignedHref = '' const originalLocation = window.location +const renderWithToastHost = (ui: React.ReactNode) => { + return render( + <> + + {ui} + , + ) +} + beforeAll(() => { Object.defineProperty(window, 'location', { configurable: true, @@ -56,6 +58,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks() + toast.close() mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true }) assignedHref = '' }) @@ -90,13 +93,10 @@ describe('SelfHostedPlanItem', () => { it('should show toast when non-manager tries to proceed', () => { mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false }) - render() + renderWithToastHost() fireEvent.click(screen.getByRole('button', { name: /billing\.plans\.premium\.btnText/ })) - expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({ - type: 'error', - message: 'billing.buyPermissionDeniedTip', - })) + expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument() }) it('should redirect to community url when community plan button clicked', () => { diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx index eaee5082ff..e1fabef96e 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx @@ -4,9 +4,9 @@ import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { Azure, GoogleCloud } from '@/app/components/base/icons/src/public/billing' +import { toast } from '@/app/components/base/ui/toast' import { useAppContext } from '@/context/app-context' import { cn } from '@/utils/classnames' -import Toast from '../../../../base/toast' import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config' import { SelfHostedPlan } from '../../../type' import { Community, Enterprise, EnterpriseNoise, Premium, PremiumNoise } from '../../assets' @@ -56,10 +56,9 @@ const SelfHostedPlanItem: FC = ({ const handleGetPayUrl = useCallback(() => { // Only workspace manager can buy plan if (!isCurrentWorkspaceManager) { - Toast.notify({ + toast.add({ type: 'error', - message: t('buyPermissionDeniedTip', { ns: 'billing' }), - className: 'z-[1001]', + title: t('buyPermissionDeniedTip', { ns: 'billing' }), }) return } @@ -82,18 +81,18 @@ const SelfHostedPlanItem: FC = ({ {/* Noise Effect */} {STYLE_MAP[plan].noise}
-
+
{STYLE_MAP[plan].icon}
{t(`${i18nPrefix}.name`, { ns: 'billing' })}
-
{t(`${i18nPrefix}.description`, { ns: 'billing' })}
+
{t(`${i18nPrefix}.description`, { ns: 'billing' })}
{/* Price */}
-
{t(`${i18nPrefix}.price`, { ns: 'billing' })}
+
{t(`${i18nPrefix}.price`, { ns: 'billing' })}
{!isFreePlan && ( - + {t(`${i18nPrefix}.priceTip`, { ns: 'billing' })} )} @@ -114,7 +113,7 @@ const SelfHostedPlanItem: FC = ({
- + {t('plans.premium.comingSoon', { ns: 'billing' })}
diff --git a/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx b/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx index 19f1f74e1d..7f1bc0e00c 100644 --- a/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/__tests__/footer.spec.tsx @@ -7,7 +7,7 @@ import Footer from '../footer' let mockSearchParams = new URLSearchParams() const mockReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }), useSearchParams: () => mockSearchParams, })) diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx index 820332dcc3..7f292c8ff9 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ import TabItem from '../tab/item' import Uploader from '../uploader' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx index ac56206003..f97b14af0f 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/__tests__/use-dsl-import.spec.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { CreateFromDSLModalTab, useDSLImport } from '../use-dsl-import' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts index c839fad3a2..ff7aa1cafb 100644 --- a/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts +++ b/web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/hooks/use-dsl-import.ts @@ -1,6 +1,5 @@ 'use client' import { useDebounceFn } from 'ahooks' -import { useRouter } from 'next/navigation' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -10,6 +9,7 @@ import { DSLImportMode, DSLImportStatus, } from '@/models/app' +import { useRouter } from '@/next/navigation' import { useImportPipelineDSL, useImportPipelineDSLConfirm } from '@/service/use-pipeline' export enum CreateFromDSLModalTab { diff --git a/web/app/components/datasets/create-from-pipeline/footer.tsx b/web/app/components/datasets/create-from-pipeline/footer.tsx index 23e83d1da3..ae1bb48394 100644 --- a/web/app/components/datasets/create-from-pipeline/footer.tsx +++ b/web/app/components/datasets/create-from-pipeline/footer.tsx @@ -1,8 +1,8 @@ import { RiFileUploadLine } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from '@/next/navigation' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import Divider from '../../base/divider' import CreateFromDSLModal, { CreateFromDSLModalTab } from './create-options/create-from-dsl-modal' diff --git a/web/app/components/datasets/create-from-pipeline/header.tsx b/web/app/components/datasets/create-from-pipeline/header.tsx index 99738edb08..204b372a1d 100644 --- a/web/app/components/datasets/create-from-pipeline/header.tsx +++ b/web/app/components/datasets/create-from-pipeline/header.tsx @@ -1,7 +1,7 @@ import { RiArrowLeftLine } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import Button from '../../base/button' const Header = () => { diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx index 96bc82f010..c4702df9c7 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import CreateCard from '../create-card' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), })) diff --git a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx index b32a7dba2d..f6a20c50e0 100644 --- a/web/app/components/datasets/create-from-pipeline/list/create-card.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/create-card.tsx @@ -1,10 +1,10 @@ import { RiAddCircleLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Toast from '@/app/components/base/toast' +import { useRouter } from '@/next/navigation' import { useCreatePipelineDataset } from '@/service/knowledge/use-create-dataset' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 4455672383..3dcff12e9d 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -6,7 +6,7 @@ import { ChunkingMode } from '@/models/datasets' import TemplateCard from '../index' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), })) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx index b3395a83d5..7684e924b6 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/index.tsx @@ -1,5 +1,4 @@ import type { PipelineTemplate } from '@/models/pipeline' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -8,6 +7,7 @@ import Confirm from '@/app/components/base/confirm' import Modal from '@/app/components/base/modal' import Toast from '@/app/components/base/toast' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' +import { useRouter } from '@/next/navigation' import { useCreatePipelineDatasetFromCustomized } from '@/service/knowledge/use-create-dataset' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { diff --git a/web/app/components/datasets/create/__tests__/index.spec.tsx b/web/app/components/datasets/create/__tests__/index.spec.tsx index 793bc21344..59d5dd891a 100644 --- a/web/app/components/datasets/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/__tests__/index.spec.tsx @@ -24,7 +24,7 @@ const IndexingTypeValues = { } // Mock next/link -vi.mock('next/link', () => { +vi.mock('@/next/link', () => { return function MockLink({ children, href }: { children: React.ReactNode, href: string }) { return
{children} } diff --git a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx index 686139250a..d1787fc47a 100644 --- a/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/embedding-process/__tests__/index.spec.tsx @@ -16,7 +16,7 @@ import { const mockPush = vi.fn() const mockRouter = { push: mockPush } -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => mockRouter, })) diff --git a/web/app/components/datasets/create/embedding-process/index.tsx b/web/app/components/datasets/create/embedding-process/index.tsx index e9cea84f00..812eb2e51c 100644 --- a/web/app/components/datasets/create/embedding-process/index.tsx +++ b/web/app/components/datasets/create/embedding-process/index.tsx @@ -6,8 +6,6 @@ import { RiLoader2Fill, RiTerminalBoxLine, } from '@remixicon/react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -15,6 +13,8 @@ import Divider from '@/app/components/base/divider' import { Plan } from '@/app/components/billing/type' import { useProviderContext } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' +import Link from '@/next/link' +import { useRouter } from '@/next/navigation' import { useProcessRule } from '@/service/knowledge/use-dataset' import { useInvalidDocumentList } from '@/service/knowledge/use-document' import IndexingProgressItem from './indexing-progress-item' diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx index f5379bc543..2df124d7b6 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import EmptyDatasetCreationModal from '../index' // Mock Next.js router const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx index 0a4064de2a..b417c15e8f 100644 --- a/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx +++ b/web/app/components/datasets/create/empty-dataset-creation-modal/index.tsx @@ -1,5 +1,4 @@ 'use client' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,6 +8,7 @@ import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import { ToastContext } from '@/app/components/base/toast/context' +import { useRouter } from '@/next/navigation' import { createEmptyDataset } from '@/service/datasets' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' diff --git a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx index da337efce2..c0635bebd1 100644 --- a/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/__tests__/index.spec.tsx @@ -58,7 +58,7 @@ vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ })) // Mock SimplePieChart -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: () => { const Component = ({ percentage }: { percentage: number }) => (
diff --git a/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx index dd88af4395..e7a25cbdd8 100644 --- a/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/file-list-item.spec.tsx @@ -17,7 +17,7 @@ vi.mock('@/types/app', () => ({ })) // Mock SimplePieChart with dynamic import handling -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: () => { const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => (
diff --git a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx index 43a944dcd4..e46ff6d484 100644 --- a/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx +++ b/web/app/components/datasets/create/step-two/components/__tests__/indexing-mode-section.spec.tsx @@ -6,7 +6,7 @@ import { ChunkingMode } from '@/models/datasets' import { IndexingType } from '../../hooks' import { IndexingModeSection } from '../indexing-mode-section' -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: { children?: React.ReactNode, href?: string, className?: string }) => {children}, })) diff --git a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx index da309348cc..8b49a00500 100644 --- a/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx +++ b/web/app/components/datasets/create/step-two/components/indexing-mode-section.tsx @@ -3,7 +3,6 @@ import type { FC } from 'react' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { RetrievalConfig } from '@/types/app' -import Link from 'next/link' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import Button from '@/app/components/base/button' @@ -16,6 +15,7 @@ import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-me import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' import { useDocLink } from '@/context/i18n' import { ChunkingMode } from '@/models/datasets' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { indexMethodIcon } from '../../icons' import { IndexingType } from '../hooks' diff --git a/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx index 4fc8d1852b..c038a371d6 100644 --- a/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/top-bar/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react' import { TopBar } from '../index' // Mock next/link to capture href values -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, replace, className }: { children: React.ReactNode, href: string, replace?: boolean, className?: string }) => ( {children} diff --git a/web/app/components/datasets/create/top-bar/index.tsx b/web/app/components/datasets/create/top-bar/index.tsx index 0051430511..ba4c49e300 100644 --- a/web/app/components/datasets/create/top-bar/index.tsx +++ b/web/app/components/datasets/create/top-bar/index.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { StepperProps } from '../stepper' import { RiArrowLeftLine } from '@remixicon/react' -import Link from 'next/link' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { Stepper } from '../stepper' diff --git a/web/app/components/datasets/documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/__tests__/index.spec.tsx index f464c97395..2dd91dd7f3 100644 --- a/web/app/components/datasets/documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/__tests__/index.spec.tsx @@ -13,7 +13,7 @@ type MockState = Parameters[0] // Mock Next.js router const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, replace: vi.fn(), diff --git a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx index 5422c23b9a..ce73368e1a 100644 --- a/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx +++ b/web/app/components/datasets/documents/components/__tests__/operations.spec.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Operations from '../operations' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx index 279c85f2f0..48e6b58766 100644 --- a/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/__tests__/index.spec.tsx @@ -9,7 +9,7 @@ import DocumentList from '../../list' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx index 1c5145f7ed..d5e4f480be 100644 --- a/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/__tests__/document-table-row.spec.tsx @@ -9,7 +9,7 @@ import DocumentTableRow from '../document-table-row' const mockPush = vi.fn() let mockSearchParams = '' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx index 3694b81138..c5f0f0af37 100644 --- a/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx +++ b/web/app/components/datasets/documents/components/document-list/components/document-table-row.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react' import type { SimpleDocumentDetail } from '@/models/datasets' import { pick } from 'es-toolkit/object' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -13,6 +12,7 @@ import SummaryStatus from '@/app/components/datasets/documents/detail/completed/ import StatusItem from '@/app/components/datasets/documents/status-item' import useTimestamp from '@/hooks/use-timestamp' import { DataSourceType } from '@/models/datasets' +import { useRouter, useSearchParams } from '@/next/navigation' import { formatNumber } from '@/utils/format' import DocumentSourceIcon from './document-source-icon' import { renderTdValue } from './utils' diff --git a/web/app/components/datasets/documents/components/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx index 84e16c7c48..ff3563c3fe 100644 --- a/web/app/components/datasets/documents/components/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -14,7 +14,6 @@ import { } from '@remixicon/react' import { useBoolean, useDebounceFn } from 'ahooks' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -28,6 +27,7 @@ import { ToastContext } from '@/app/components/base/toast/context' import Tooltip from '@/app/components/base/tooltip' import { IS_CE_EDITION } from '@/config' import { DataSourceType, DocumentActionType } from '@/models/datasets' +import { useRouter } from '@/next/navigation' import { useDocumentArchive, useDocumentDelete, diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index 0096dc8c29..8a2e251770 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -90,7 +90,7 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: vi.fn(), @@ -101,7 +101,7 @@ vi.mock('next/navigation', () => ({ })) // Mock next/link -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => ( {children} ), diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx index 584c21e826..c4ddec7434 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/left-header.spec.tsx @@ -3,11 +3,11 @@ import { render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import LeftHeader from '../left-header' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-ds-id' }), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => ( {children} ), diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx index 45ecaa7e9b..93861ef76a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/__tests__/index.spec.tsx @@ -4,12 +4,12 @@ import Actions from '../index' // Mock next/navigation - useParams returns datasetId const mockDatasetId = 'test-dataset-id' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: mockDatasetId }), })) // Mock next/link to capture href -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, replace }: { children: React.ReactNode, href: string, replace?: boolean }) => ( {children} diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx index de0609b4d8..dab76da832 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/actions/index.tsx @@ -1,11 +1,11 @@ import { RiArrowRightLine } from '@remixicon/react' -import Link from 'next/link' -import { useParams } from 'next/navigation' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Checkbox from '@/app/components/base/checkbox' +import Link from '@/next/link' +import { useParams } from '@/next/navigation' type ActionsProps = { disabled?: boolean diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx index 87010638b2..4ec21ab1fb 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/__tests__/index.spec.tsx @@ -26,7 +26,7 @@ vi.mock('@/app/components/datasets/common/document-file-icon', () => ({ })) // Mock SimplePieChart -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: () => { const Component = ({ percentage }: { percentage: number }) => (
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx index df7fe3540b..fcb0878978 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx @@ -17,7 +17,7 @@ vi.mock('@/types/app', () => ({ })) // Mock SimplePieChart with dynamic import handling -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: () => { const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => (
diff --git a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx index 2b30c79022..d464041d13 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx @@ -1,10 +1,10 @@ import type { Step } from './step-indicator' import { RiArrowLeftLine } from '@remixicon/react' -import Link from 'next/link' -import { useParams } from 'next/navigation' import * as React from 'react' import Button from '@/app/components/base/button' import Effect from '@/app/components/base/effect' +import Link from '@/next/link' +import { useParams } from '@/next/navigation' import StepIndicator from './step-indicator' type LeftHeaderProps = { diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx index aa107b8635..f59f5c091b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx @@ -10,14 +10,14 @@ import { RETRIEVE_METHOD } from '@/types/app' import EmbeddingProcess from '../index' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), })) // Mock next/link -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: function MockLink({ children, href, ...props }: { children: React.ReactNode, href: string }) { return {children} }, diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx index a7834fc656..099c3018cd 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx @@ -10,8 +10,6 @@ import { RiLoader2Fill, RiTerminalBoxLine, } from '@remixicon/react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -26,6 +24,8 @@ import DocumentFileIcon from '@/app/components/datasets/common/document-file-ico import { useProviderContext } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import { DatasourceType } from '@/models/pipeline' +import Link from '@/next/link' +import { useRouter } from '@/next/navigation' import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset' import { useInvalidDocumentList } from '@/service/knowledge/use-document' import { cn } from '@/utils/classnames' diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx index ff0c1b125c..2e121dbbd1 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx @@ -143,7 +143,7 @@ vi.mock('@/service/base', () => ({ upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'mock-dataset-id' }), useRouter: () => ({ push: vi.fn() }), usePathname: () => '/datasets/mock-dataset-id', diff --git a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx index e7945fc409..3eb1017b8d 100644 --- a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx @@ -5,7 +5,7 @@ import { ChunkingMode } from '@/models/datasets' import { DocumentTitle } from '../document-title' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx index f01a64e34e..be4d2304bd 100644 --- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx @@ -25,7 +25,7 @@ const mocks = vi.hoisted(() => { }) // --- External mocks --- -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), useSearchParams: () => new URLSearchParams(mocks.state.searchParams), })) diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx index 73082108a0..97287822c3 100644 --- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx @@ -1,26 +1,19 @@ -import type * as React from 'react' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast, ToastHost } from '@/app/components/base/ui/toast' import { ChunkingMode } from '@/models/datasets' import { IndexingType } from '../../../create/step-two' import NewSegmentModal from '../new-segment' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id', documentId: 'test-document-id', }), })) -const mockNotify = vi.fn() -vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() as Record - return { - ...actual, - useContext: () => ({ notify: mockNotify }), - } -}) +const toastAddSpy = vi.spyOn(toast, 'add') // Mock dataset detail context let mockIndexingTechnique = IndexingType.QUALIFIED @@ -51,11 +44,6 @@ vi.mock('@/service/knowledge/use-segment', () => ({ }), })) -// Mock app store -vi.mock('@/app/components/app/store', () => ({ - useStore: () => ({ appSidebarExpand: 'expand' }), -})) - vi.mock('../completed/common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, actionType }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string }) => (
@@ -139,6 +127,8 @@ vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-chunk describe('NewSegmentModal', () => { beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() + toast.close() mockFullScreen = false mockIndexingTechnique = IndexingType.QUALIFIED }) @@ -258,7 +248,7 @@ describe('NewSegmentModal', () => { fireEvent.click(screen.getByTestId('save-btn')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( + expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', }), @@ -272,7 +262,7 @@ describe('NewSegmentModal', () => { fireEvent.click(screen.getByTestId('save-btn')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( + expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', }), @@ -287,7 +277,7 @@ describe('NewSegmentModal', () => { fireEvent.click(screen.getByTestId('save-btn')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( + expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', }), @@ -337,7 +327,7 @@ describe('NewSegmentModal', () => { fireEvent.click(screen.getByTestId('save-btn')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( + expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'success', }), @@ -430,10 +420,9 @@ describe('NewSegmentModal', () => { }) }) - describe('CustomButton in success notification', () => { - it('should call viewNewlyAddedChunk when custom button is clicked', async () => { + describe('Action button in success notification', () => { + it('should call viewNewlyAddedChunk when the toast action is clicked', async () => { const mockViewNewlyAddedChunk = vi.fn() - mockNotify.mockImplementation(() => {}) mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { options.onSuccess() @@ -442,37 +431,25 @@ describe('NewSegmentModal', () => { }) render( - , + <> + + + , ) - // Enter content and save fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } }) fireEvent.click(screen.getByTestId('save-btn')) + const actionButton = await screen.findByRole('button', { name: 'common.operation.view' }) + fireEvent.click(actionButton) + await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'success', - customComponent: expect.anything(), - }), - ) + expect(mockViewNewlyAddedChunk).toHaveBeenCalledTimes(1) }) - - // Extract customComponent from the notify call args - const notifyCallArgs = mockNotify.mock.calls[0][0] as { customComponent?: React.ReactElement } - expect(notifyCallArgs.customComponent).toBeDefined() - const customComponent = notifyCallArgs.customComponent! - const { container: btnContainer } = render(customComponent) - const viewButton = btnContainer.querySelector('.system-xs-semibold.text-text-accent') as HTMLElement - expect(viewButton).toBeInTheDocument() - fireEvent.click(viewButton) - - // Assert that viewNewlyAddedChunk was called via the onClick handler (lines 66-67) - expect(mockViewNewlyAddedChunk).toHaveBeenCalled() }) }) @@ -599,9 +576,8 @@ describe('NewSegmentModal', () => { }) }) - describe('onSave delayed call', () => { - it('should call onSave after timeout in success handler', async () => { - vi.useFakeTimers() + describe('onSave after success', () => { + it('should call onSave immediately after save succeeds', async () => { const mockOnSave = vi.fn() mockAddSegment.mockImplementation((_params: unknown, options: { onSuccess: () => void, onSettled: () => void }) => { options.onSuccess() @@ -611,15 +587,12 @@ describe('NewSegmentModal', () => { render() - // Enter content and save fireEvent.change(screen.getByTestId('question-input'), { target: { value: 'Test content' } }) fireEvent.click(screen.getByTestId('save-btn')) - // Fast-forward timer - vi.advanceTimersByTime(3000) - - expect(mockOnSave).toHaveBeenCalled() - vi.useRealTimers() + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx index 59ecbf5f25..2a68e6f627 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx @@ -49,7 +49,7 @@ const { mockOnDelete: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => '/datasets/test-dataset-id/documents/test-document-id', })) diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx index 1b26a15b65..b9c8bf80ba 100644 --- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx +++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx @@ -1,23 +1,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast, ToastHost } from '@/app/components/base/ui/toast' import NewChildSegmentModal from '../new-child-segment' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id', documentId: 'test-document-id', }), })) -const mockNotify = vi.fn() -vi.mock('use-context-selector', async (importOriginal) => { - const actual = await importOriginal() as Record - return { - ...actual, - useContext: () => ({ notify: mockNotify }), - } -}) +const toastAddSpy = vi.spyOn(toast, 'add') // Mock document context let mockParentMode = 'paragraph' @@ -48,11 +42,6 @@ vi.mock('@/service/knowledge/use-segment', () => ({ }), })) -// Mock app store -vi.mock('@/app/components/app/store', () => ({ - useStore: () => ({ appSidebarExpand: 'expand' }), -})) - vi.mock('../common/action-buttons', () => ({ default: ({ handleCancel, handleSave, loading, actionType, isChildChunk }: { handleCancel: () => void, handleSave: () => void, loading: boolean, actionType: string, isChildChunk?: boolean }) => (
@@ -103,6 +92,8 @@ vi.mock('../common/segment-index-tag', () => ({ describe('NewChildSegmentModal', () => { beforeEach(() => { vi.clearAllMocks() + vi.useRealTimers() + toast.close() mockFullScreen = false mockParentMode = 'paragraph' }) @@ -198,7 +189,7 @@ describe('NewChildSegmentModal', () => { fireEvent.click(screen.getByTestId('save-btn')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( + expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', }), @@ -253,7 +244,7 @@ describe('NewChildSegmentModal', () => { fireEvent.click(screen.getByTestId('save-btn')) await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( + expect(toastAddSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'success', }), @@ -374,35 +365,62 @@ describe('NewChildSegmentModal', () => { // View newly added chunk describe('View Newly Added Chunk', () => { - it('should show custom button in full-doc mode after save', async () => { + it('should call viewNewlyAddedChildChunk when the toast action is clicked', async () => { mockParentMode = 'full-doc' + const mockViewNewlyAddedChildChunk = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { options.onSuccess({ data: { id: 'new-child-id' } }) options.onSettled() return Promise.resolve() }) - render() + render( + <> + + + , + ) - // Enter valid content fireEvent.change(screen.getByTestId('content-input'), { target: { value: 'Valid content' }, }) fireEvent.click(screen.getByTestId('save-btn')) - // Assert - success notification with custom component + const actionButton = await screen.findByRole('button', { name: 'common.operation.view' }) + fireEvent.click(actionButton) + await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'success', - customComponent: expect.anything(), - }), - ) + expect(mockViewNewlyAddedChildChunk).toHaveBeenCalledTimes(1) }) }) - it('should not show custom button in paragraph mode after save', async () => { + it('should call onSave immediately in full-doc mode after save succeeds', async () => { + mockParentMode = 'full-doc' + const mockOnSave = vi.fn() + mockAddChildSegment.mockImplementation((_params, options) => { + options.onSuccess({ data: { id: 'new-child-id' } }) + options.onSettled() + return Promise.resolve() + }) + + render() + + fireEvent.change(screen.getByTestId('content-input'), { + target: { value: 'Valid content' }, + }) + + fireEvent.click(screen.getByTestId('save-btn')) + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledTimes(1) + }) + }) + + it('should call onSave with the new child chunk in paragraph mode', async () => { mockParentMode = 'paragraph' const mockOnSave = vi.fn() mockAddChildSegment.mockImplementation((_params, options) => { diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts index f54c00e3e7..6e9239c972 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts @@ -68,7 +68,7 @@ const { mockPathname: { current: '/datasets/test/documents/test' }, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => mockPathname.current, })) diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts index aa91e9f464..8948f6b547 100644 --- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts +++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts @@ -1,12 +1,12 @@ import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets' import { useQueryClient } from '@tanstack/react-query' -import { usePathname } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { useToastContext } from '@/app/components/base/toast/context' import { useEventEmitterContextContext } from '@/context/event-emitter' import { ChunkingMode } from '@/models/datasets' +import { usePathname } from '@/next/navigation' import { useChunkListAllKey, useChunkListDisabledKey, diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx index e28fb774fb..bc9200c5be 100644 --- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx +++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx @@ -1,15 +1,12 @@ import type { FC } from 'react' import type { ChildChunkDetail, SegmentUpdater } from '@/models/datasets' import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react' -import { useParams } from 'next/navigation' -import { memo, useMemo, useRef, useState } from 'react' +import { memo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' -import { useShallow } from 'zustand/react/shallow' -import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' -import { ToastContext } from '@/app/components/base/toast/context' +import { toast } from '@/app/components/base/ui/toast' import { ChunkingMode } from '@/models/datasets' +import { useParams } from '@/next/navigation' import { useAddChildSegment } from '@/service/knowledge/use-segment' import { cn } from '@/utils/classnames' import { formatNumber } from '@/utils/format' @@ -35,39 +32,15 @@ const NewChildSegmentModal: FC = ({ viewNewlyAddedChildChunk, }) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const [content, setContent] = useState('') const { datasetId, documentId } = useParams<{ datasetId: string, documentId: string }>() const [loading, setLoading] = useState(false) const [addAnother, setAddAnother] = useState(true) const fullScreen = useSegmentListContext(s => s.fullScreen) const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen) - const { appSidebarExpand } = useAppStore(useShallow(state => ({ - appSidebarExpand: state.appSidebarExpand, - }))) const parentMode = useDocumentContext(s => s.parentMode) - const refreshTimer = useRef(null) - - const isFullDocMode = useMemo(() => { - return parentMode === 'full-doc' - }, [parentMode]) - - const CustomButton = ( - <> - - - - ) + const isFullDocMode = parentMode === 'full-doc' const handleCancel = (actionType: 'esc' | 'add' = 'esc') => { if (actionType === 'esc' || !addAnother) @@ -80,26 +53,27 @@ const NewChildSegmentModal: FC = ({ const params: SegmentUpdater = { content: '' } if (!content.trim()) - return notify({ type: 'error', message: t('segment.contentEmpty', { ns: 'datasetDocuments' }) }) + return toast.add({ type: 'error', title: t('segment.contentEmpty', { ns: 'datasetDocuments' }) }) params.content = content setLoading(true) await addChildSegment({ datasetId, documentId, segmentId: chunkId, body: params }, { onSuccess(res) { - notify({ + toast.add({ type: 'success', - message: t('segment.childChunkAdded', { ns: 'datasetDocuments' }), - className: `!w-[296px] !bottom-0 ${appSidebarExpand === 'expand' ? '!left-[216px]' : '!left-14'} - !top-auto !right-auto !mb-[52px] !ml-11`, - customComponent: isFullDocMode && CustomButton, + title: t('segment.childChunkAdded', { ns: 'datasetDocuments' }), + actionProps: isFullDocMode + ? { + children: t('operation.view', { ns: 'common' }), + onClick: viewNewlyAddedChildChunk, + } + : undefined, }) handleCancel('add') setContent('') if (isFullDocMode) { - refreshTimer.current = setTimeout(() => { - onSave() - }, 3000) + onSave() } else { onSave(res.data) @@ -111,10 +85,8 @@ const NewChildSegmentModal: FC = ({ }) } - const wordCountText = useMemo(() => { - const count = content.length - return `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}` - }, [content.length]) + const count = content.length + const wordCountText = `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}` return (
diff --git a/web/app/components/datasets/documents/detail/document-title.tsx b/web/app/components/datasets/documents/detail/document-title.tsx index ec44e3ea97..2190338ab2 100644 --- a/web/app/components/datasets/documents/detail/document-title.tsx +++ b/web/app/components/datasets/documents/detail/document-title.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react' import type { ChunkingMode, ParentMode } from '@/models/datasets' -import { useRouter } from 'next/navigation' +import { useRouter } from '@/next/navigation' import { cn } from '@/utils/classnames' import DocumentPicker from '../../common/document-picker' diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index b6842605c6..891c177169 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { FC } from 'react' import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -13,6 +12,7 @@ import Metadata from '@/app/components/datasets/metadata/metadata-document' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { ChunkingMode } from '@/models/datasets' +import { useRouter, useSearchParams } from '@/next/navigation' import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx index d2e27e9969..9cbb4746f9 100644 --- a/web/app/components/datasets/documents/detail/new-segment.tsx +++ b/web/app/components/datasets/documents/detail/new-segment.tsx @@ -2,17 +2,14 @@ import type { FC } from 'react' import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types' import type { SegmentUpdater } from '@/models/datasets' import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react' -import { useParams } from 'next/navigation' -import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' -import { useShallow } from 'zustand/react/shallow' -import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' -import { ToastContext } from '@/app/components/base/toast/context' +import { toast } from '@/app/components/base/ui/toast' import ImageUploaderInChunk from '@/app/components/datasets/common/image-uploader/image-uploader-in-chunk' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { ChunkingMode } from '@/models/datasets' +import { useParams } from '@/next/navigation' import { useAddSegment } from '@/service/knowledge/use-segment' import { cn } from '@/utils/classnames' import { formatNumber } from '@/utils/format' @@ -39,7 +36,6 @@ const NewSegmentModal: FC = ({ viewNewlyAddedChunk, }) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) const [question, setQuestion] = useState('') const [answer, setAnswer] = useState('') const [attachments, setAttachments] = useState([]) @@ -50,27 +46,7 @@ const NewSegmentModal: FC = ({ const fullScreen = useSegmentListContext(s => s.fullScreen) const toggleFullScreen = useSegmentListContext(s => s.toggleFullScreen) const indexingTechnique = useDatasetDetailContextWithSelector(s => s.dataset?.indexing_technique) - const { appSidebarExpand } = useAppStore(useShallow(state => ({ - appSidebarExpand: state.appSidebarExpand, - }))) - const [imageUploaderKey, setImageUploaderKey] = useState(Date.now()) - const refreshTimer = useRef(null) - - const CustomButton = useMemo(() => ( - <> - - - - ), [viewNewlyAddedChunk, t]) + const [imageUploaderKey, setImageUploaderKey] = useState(() => Date.now()) const handleCancel = useCallback((actionType: 'esc' | 'add' = 'esc') => { if (actionType === 'esc' || !addAnother) @@ -87,15 +63,15 @@ const NewSegmentModal: FC = ({ const params: SegmentUpdater = { content: '', attachment_ids: [] } if (docForm === ChunkingMode.qa) { if (!question.trim()) { - return notify({ + return toast.add({ type: 'error', - message: t('segment.questionEmpty', { ns: 'datasetDocuments' }), + title: t('segment.questionEmpty', { ns: 'datasetDocuments' }), }) } if (!answer.trim()) { - return notify({ + return toast.add({ type: 'error', - message: t('segment.answerEmpty', { ns: 'datasetDocuments' }), + title: t('segment.answerEmpty', { ns: 'datasetDocuments' }), }) } @@ -104,9 +80,9 @@ const NewSegmentModal: FC = ({ } else { if (!question.trim()) { - return notify({ + return toast.add({ type: 'error', - message: t('segment.contentEmpty', { ns: 'datasetDocuments' }), + title: t('segment.contentEmpty', { ns: 'datasetDocuments' }), }) } @@ -122,12 +98,13 @@ const NewSegmentModal: FC = ({ setLoading(true) await addSegment({ datasetId, documentId, body: params }, { onSuccess() { - notify({ + toast.add({ type: 'success', - message: t('segment.chunkAdded', { ns: 'datasetDocuments' }), - className: `!w-[296px] !bottom-0 ${appSidebarExpand === 'expand' ? '!left-[216px]' : '!left-14'} - !top-auto !right-auto !mb-[52px] !ml-11`, - customComponent: CustomButton, + title: t('segment.chunkAdded', { ns: 'datasetDocuments' }), + actionProps: { + children: t('operation.view', { ns: 'common' }), + onClick: viewNewlyAddedChunk, + }, }) handleCancel('add') setQuestion('') @@ -135,20 +112,16 @@ const NewSegmentModal: FC = ({ setAttachments([]) setImageUploaderKey(Date.now()) setKeywords([]) - refreshTimer.current = setTimeout(() => { - onSave() - }, 3000) + onSave() }, onSettled() { setLoading(false) }, }) - }, [docForm, keywords, addSegment, datasetId, documentId, question, answer, attachments, notify, t, appSidebarExpand, CustomButton, handleCancel, onSave]) + }, [docForm, keywords, addSegment, datasetId, documentId, question, answer, attachments, t, handleCancel, onSave, viewNewlyAddedChunk]) - const wordCountText = useMemo(() => { - const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length - return `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}` - }, [question.length, answer.length, docForm, t]) + const count = docForm === ChunkingMode.qa ? (question.length + answer.length) : question.length + const wordCountText = `${formatNumber(count)} ${t('segment.characters', { ns: 'datasetDocuments', count })}` const isECOIndexing = indexingTechnique === IndexingType.ECONOMICAL diff --git a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx index 84534298c9..4ac30289e1 100644 --- a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx @@ -5,7 +5,7 @@ import DocumentSettings from '../document-settings' const mockPush = vi.fn() const mockBack = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, back: mockBack, diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx index 67773cb7d6..bcbc149231 100644 --- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx +++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx @@ -11,7 +11,6 @@ import type { WebsiteCrawlInfo, } from '@/models/datasets' import { useBoolean } from 'ahooks' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -24,6 +23,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks' import DatasetDetailContext from '@/context/dataset-detail' +import { useRouter } from '@/next/navigation' import { useDocumentDetail, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' type DocumentSettingsProps = { diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx index 9f2ccc0acd..764667c55c 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import PipelineSettings from '../index' // Mock Next.js router const mockPush = vi.fn() const mockBack = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, back: mockBack, diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx index 9a1ffab673..30019ca67d 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import LeftHeader from '../left-header' const mockBack = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ back: mockBack, }), diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx index 08e13765e5..4c9dd641e3 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx @@ -2,13 +2,13 @@ import type { NotionPage } from '@/models/common' import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets' import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { DatasourceType } from '@/models/pipeline' +import { useRouter } from '@/next/navigation' import { useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' import { usePipelineExecutionLog, useRunPublishedPipeline } from '@/service/use-pipeline' import ChunkPreview from '../../../create-from-pipeline/preview/chunk-preview' diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx index 280d835586..15b06a5f10 100644 --- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx +++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx @@ -1,10 +1,10 @@ import { RiArrowLeftLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Effect from '@/app/components/base/effect' +import { useRouter } from '@/next/navigation' type LeftHeaderProps = { title: string diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index 764b04227c..29d9c01f71 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' -import { useRouter } from 'next/navigation' import { useCallback } from 'react' import Loading from '@/app/components/base/loading' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' +import { useRouter } from '@/next/navigation' import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' diff --git a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx index a6a60aa856..c948450f1b 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import ExternalKnowledgeBaseConnector from '../index' const mockRouterBack = vi.fn() const mockReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ back: mockRouterBack, replace: mockReplace, @@ -21,11 +21,11 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`, })) -const mockNotify = vi.fn() -vi.mock('@/app/components/base/toast/context', () => ({ - useToastContext: () => ({ - notify: mockNotify, - }), +const mockNotify = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockNotify, + }, })) // Mock modal context @@ -164,7 +164,7 @@ describe('ExternalKnowledgeBaseConnector', () => { // Verify success notification expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - message: 'External Knowledge Base Connected Successfully', + title: 'External Knowledge Base Connected Successfully', }) // Verify navigation back @@ -206,7 +206,7 @@ describe('ExternalKnowledgeBaseConnector', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'Failed to connect External Knowledge Base', + title: 'Failed to connect External Knowledge Base', }) }) @@ -228,7 +228,7 @@ describe('ExternalKnowledgeBaseConnector', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - message: 'Failed to connect External Knowledge Base', + title: 'Failed to connect External Knowledge Base', }) }) @@ -274,7 +274,7 @@ describe('ExternalKnowledgeBaseConnector', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - message: 'External Knowledge Base Connected Successfully', + title: 'External Knowledge Base Connected Successfully', }) }) }) diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.tsx b/web/app/components/datasets/external-knowledge-base/connector/index.tsx index cf36eed382..6ff7014f47 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/index.tsx @@ -1,16 +1,15 @@ 'use client' import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useState } from 'react' import { trackEvent } from '@/app/components/base/amplitude' -import { useToastContext } from '@/app/components/base/toast/context' +import { toast } from '@/app/components/base/ui/toast' import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create' +import { useRouter } from '@/next/navigation' import { createExternalKnowledgeBase } from '@/service/datasets' const ExternalKnowledgeBaseConnector = () => { - const { notify } = useToastContext() const [loading, setLoading] = useState(false) const router = useRouter() @@ -19,7 +18,7 @@ const ExternalKnowledgeBaseConnector = () => { setLoading(true) const result = await createExternalKnowledgeBase({ body: formValue }) if (result && result.id) { - notify({ type: 'success', message: 'External Knowledge Base Connected Successfully' }) + toast.add({ type: 'success', title: 'External Knowledge Base Connected Successfully' }) trackEvent('create_external_knowledge_base', { provider: formValue.provider, name: formValue.name, @@ -30,7 +29,7 @@ const ExternalKnowledgeBaseConnector = () => { } catch (error) { console.error('Error creating external knowledge base:', error) - notify({ type: 'error', message: 'Failed to connect External Knowledge Base' }) + toast.add({ type: 'error', title: 'Failed to connect External Knowledge Base' }) } setLoading(false) } diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx index f84e6c57c1..a527da982a 100644 --- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx @@ -2,13 +2,13 @@ import { RiAddLine, RiArrowDownSLine, } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development' import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context' import { useModalContext } from '@/context/modal-context' +import { useRouter } from '@/next/navigation' type ApiItem = { value: string diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx index 75b9e8de9c..4652a8a5f1 100644 --- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx @@ -1,7 +1,6 @@ 'use client' import { RiAddLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,6 +8,7 @@ import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context' import { useModalContext } from '@/context/modal-context' +import { useRouter } from '@/next/navigation' import ExternalApiSelect from './ExternalApiSelect' type ExternalApiSelectionProps = { diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx index 3b8b35a5b7..7af75fbcdd 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx @@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({ mutateExternalKnowledgeApis: vi.fn(), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), })) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx index 702890bee9..97934f36e1 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ externalKnowledgeApiList: [] as Array<{ id: string, name: string, settings: { endpoint: string } }>, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }), })) diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx index 213fe30ee3..a3282e441c 100644 --- a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx @@ -7,7 +7,7 @@ import RetrievalSettings from '../RetrievalSettings' const mockReplace = vi.fn() const mockRefresh = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, push: vi.fn(), diff --git a/web/app/components/datasets/external-knowledge-base/create/index.tsx b/web/app/components/datasets/external-knowledge-base/create/index.tsx index 07b6e71fa6..0e855259ba 100644 --- a/web/app/components/datasets/external-knowledge-base/create/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/create/index.tsx @@ -2,12 +2,12 @@ import type { CreateKnowledgeBaseReq } from './declarations' import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { useDocLink } from '@/context/i18n' +import { useRouter } from '@/next/navigation' import ExternalApiSelection from './ExternalApiSelection' import InfoPanel from './InfoPanel' import KnowledgeBaseInfo from './KnowledgeBaseInfo' diff --git a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx index 4a8d89e9fb..de61894a11 100644 --- a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx @@ -13,7 +13,7 @@ import Statistics from '../statistics' // Mock Setup -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), @@ -23,7 +23,7 @@ vi.mock('next/navigation', () => ({ })) // Mock next/link -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( {children} ), diff --git a/web/app/components/datasets/extra-info/api-access/card.tsx b/web/app/components/datasets/extra-info/api-access/card.tsx index 946536bf2c..eee586ff8e 100644 --- a/web/app/components/datasets/extra-info/api-access/card.tsx +++ b/web/app/components/datasets/extra-info/api-access/card.tsx @@ -1,5 +1,4 @@ import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' @@ -8,6 +7,7 @@ import Indicator from '@/app/components/header/indicator' import { useSelector as useAppContextSelector } from '@/context/app-context' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' +import Link from '@/next/link' import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset' import { cn } from '@/utils/classnames' diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx index b94508de6a..8137052383 100644 --- a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx +++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx @@ -9,7 +9,7 @@ import ServiceApi from '../index' // Mock Setup -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), @@ -19,7 +19,7 @@ vi.mock('next/navigation', () => ({ })) // Mock next/link -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( {children} ), diff --git a/web/app/components/datasets/extra-info/service-api/card.tsx b/web/app/components/datasets/extra-info/service-api/card.tsx index 31076d12fc..bf84204ea4 100644 --- a/web/app/components/datasets/extra-info/service-api/card.tsx +++ b/web/app/components/datasets/extra-info/service-api/card.tsx @@ -1,5 +1,4 @@ import { RiBookOpenLine, RiKey2Line } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,6 +8,7 @@ import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge' import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal' import Indicator from '@/app/components/header/indicator' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' +import Link from '@/next/link' type CardProps = { apiBaseUrl: string diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx index fe7510b498..2dda6ecaae 100644 --- a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx +++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSetti // Mock Setup -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), diff --git a/web/app/components/datasets/list/__tests__/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx index 49bda88c8b..5b777e0b2e 100644 --- a/web/app/components/datasets/list/__tests__/datasets.spec.tsx +++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx @@ -6,7 +6,7 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase import { RETRIEVE_METHOD } from '@/types/app' import Datasets from '../datasets' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), })) diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index 73e0ba0960..37a787ff51 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -4,7 +4,7 @@ import List from '../index' const mockPush = vi.fn() const mockReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, replace: mockReplace, diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx index ebe80e4686..21ddda5ce6 100644 --- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx @@ -22,7 +22,7 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({ const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), })) diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx index 85dba7e8ff..2a22255eda 100644 --- a/web/app/components/datasets/list/dataset-card/index.tsx +++ b/web/app/components/datasets/list/dataset-card/index.tsx @@ -1,9 +1,9 @@ 'use client' import type { DataSet } from '@/models/datasets' import { useHover } from 'ahooks' -import { useRouter } from 'next/navigation' import { useMemo, useRef } from 'react' import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import CornerLabels from './components/corner-labels' import DatasetCardFooter from './components/dataset-card-footer' import DatasetCardHeader from './components/dataset-card-header' diff --git a/web/app/components/datasets/list/new-dataset-card/option.tsx b/web/app/components/datasets/list/new-dataset-card/option.tsx index e862b5c11e..05b14fef1a 100644 --- a/web/app/components/datasets/list/new-dataset-card/option.tsx +++ b/web/app/components/datasets/list/new-dataset-card/option.tsx @@ -1,5 +1,5 @@ -import Link from 'next/link' import * as React from 'react' +import Link from '@/next/link' type OptionProps = { Icon: React.ComponentType<{ className?: string }> diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx index e56fe46422..9cc4f89bd8 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx @@ -45,7 +45,7 @@ vi.mock('../../hooks/use-check-metadata-name', () => ({ }), })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), }), diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx index f30e188cd7..d783b882a8 100644 --- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx +++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx @@ -22,7 +22,7 @@ type InputCombinedProps = { type: DataType } -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), }), diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx index 6d172c92f4..0b21d607bd 100644 --- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx +++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx @@ -2,12 +2,12 @@ import type { FC } from 'react' import type { MetadataItemWithValue } from '../types' import { RiDeleteBinLine, RiQuestionLine } from '@remixicon/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Tooltip from '@/app/components/base/tooltip' import useTimestamp from '@/hooks/use-timestamp' +import { useRouter } from '@/next/navigation' import { cn } from '@/utils/classnames' import AddMetadataButton from '../add-metadata-button' import InputCombined from '../edit-metadata-batch/input-combined' diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index cf76593613..5c743928e8 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -8,7 +8,7 @@ const mockReplace = vi.fn() const mockPush = vi.fn() const mockInstalledAppsData = { installed_apps: [] as const } -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, push: mockPush, diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 62353fb3c1..f389eeab29 100644 --- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -19,7 +19,7 @@ vi.mock('@emoji-mart/data', () => ({ }, })) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({}), })) diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx index 36e6ab217c..e29a12a17f 100644 --- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx @@ -1,19 +1,23 @@ import type { InstalledApp } from '@/models/explore' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import Toast from '@/app/components/base/toast' import { MediaType } from '@/hooks/use-breakpoints' import { AppModeEnum } from '@/types/app' import SideBar from '../index' +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), +})) + const mockSegments = ['apps'] const mockPush = vi.fn() const mockUninstall = vi.fn() const mockUpdatePinStatus = vi.fn() let mockIsPending = false +let mockIsUninstallPending = false let mockInstalledApps: InstalledApp[] = [] let mockMediaType: string = MediaType.pc -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegments: () => mockSegments, useRouter: () => ({ push: mockPush, @@ -36,12 +40,22 @@ vi.mock('@/service/use-explore', () => ({ }), useUninstallApp: () => ({ mutateAsync: mockUninstall, + isPending: mockIsUninstallPending, }), useUpdateAppPinStatus: () => ({ mutateAsync: mockUpdatePinStatus, }), })) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockToastAdd, + close: vi.fn(), + update: vi.fn(), + promise: vi.fn(), + }, +})) + const createInstalledApp = (overrides: Partial = {}): InstalledApp => ({ id: overrides.id ?? 'app-123', uninstallable: overrides.uninstallable ?? false, @@ -67,9 +81,9 @@ describe('SideBar', () => { beforeEach(() => { vi.clearAllMocks() mockIsPending = false + mockIsUninstallPending = false mockInstalledApps = [] mockMediaType = MediaType.pc - vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) }) describe('Rendering', () => { @@ -84,6 +98,7 @@ describe('SideBar', () => { renderSideBar() expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'explore.sidebar.webApps' })).toBeInTheDocument() expect(screen.getByText('My App')).toBeInTheDocument() }) @@ -135,9 +150,9 @@ describe('SideBar', () => { await waitFor(() => { expect(mockUninstall).toHaveBeenCalledWith('app-123') - expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', - message: 'common.api.remove', + title: 'common.api.remove', })) }) }) @@ -152,9 +167,9 @@ describe('SideBar', () => { await waitFor(() => { expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true }) - expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({ + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'success', - message: 'common.api.success', + title: 'common.api.success', })) }) }) @@ -187,6 +202,18 @@ describe('SideBar', () => { expect(mockUninstall).not.toHaveBeenCalled() }) }) + + it('should disable dialog actions while uninstall is pending', async () => { + mockInstalledApps = [createInstalledApp()] + mockIsUninstallPending = true + renderSideBar() + + fireEvent.click(screen.getByTestId('item-operation-trigger')) + fireEvent.click(await screen.findByText('explore.sidebar.action.delete')) + + expect(screen.getByText('common.operation.cancel')).toBeDisabled() + expect(screen.getByText('common.operation.confirm')).toBeDisabled() + }) }) describe('Edge Cases', () => { diff --git a/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx index 299c181c98..26af458c55 100644 --- a/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import AppNavItem from '../index' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, }), diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx index 08558578f6..3f3d7a727e 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx @@ -2,11 +2,11 @@ import type { AppIconType } from '@/types/app' import { useHover } from 'ahooks' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useRef } from 'react' import AppIcon from '@/app/components/base/app-icon' import ItemOperation from '@/app/components/explore/item-operation' +import { useRouter } from '@/next/navigation' import { cn } from '@/utils/classnames' export type IAppNavItemProps = { diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index bafc745b01..032430909d 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -1,19 +1,42 @@ 'use client' import { useBoolean } from 'ahooks' -import Link from 'next/link' -import { useSelectedLayoutSegments } from 'next/navigation' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Confirm from '@/app/components/base/confirm' import Divider from '@/app/components/base/divider' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@/app/components/base/ui/alert-dialog' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '@/app/components/base/ui/scroll-area' +import { toast } from '@/app/components/base/ui/toast' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import Link from '@/next/link' +import { useSelectedLayoutSegments } from '@/next/navigation' import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore' import { cn } from '@/utils/classnames' -import Toast from '../../base/toast' import Item from './app-nav-item' import NoApps from './no-apps' +const expandedSidebarScrollAreaClassNames = { + root: 'h-full', + viewport: 'overscroll-contain', + content: 'space-y-0.5', + scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:[margin-inline-end:-0.75rem]', + thumb: 'rounded-full', +} as const + const SideBar = () => { const { t } = useTranslation() const segments = useSelectedLayoutSegments() @@ -21,7 +44,7 @@ const SideBar = () => { const isDiscoverySelected = lastSegment === 'apps' const { data, isPending } = useGetInstalledApps() const installedApps = data?.installed_apps ?? [] - const { mutateAsync: uninstallApp } = useUninstallApp() + const { mutateAsync: uninstallApp, isPending: isUninstalling } = useUninstallApp() const { mutateAsync: updatePinStatus } = useUpdateAppPinStatus() const media = useBreakpoints() @@ -36,23 +59,48 @@ const SideBar = () => { const id = currId await uninstallApp(id) setShowConfirm(false) - Toast.notify({ + toast.add({ type: 'success', - message: t('api.remove', { ns: 'common' }), + title: t('api.remove', { ns: 'common' }), }) } const handleUpdatePinStatus = async (id: string, isPinned: boolean) => { await updatePinStatus({ appId: id, isPinned }) - Toast.notify({ + toast.add({ type: 'success', - message: t('api.success', { ns: 'common' }), + title: t('api.success', { ns: 'common' }), }) } const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length + const shouldUseExpandedScrollArea = !isMobile && !isFold + const webAppsLabelId = React.useId() + const installedAppItems = installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( + + handleUpdatePinStatus(id, !is_pinned)} + uninstallable={uninstallable} + onDelete={(id) => { + setCurrId(id) + setShowConfirm(true) + }} + /> + {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && } + + )) + return ( -
+
{ )} {installedApps.length > 0 && ( -
- {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

} -
- {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( - - handleUpdatePinStatus(id, !is_pinned)} - uninstallable={uninstallable} - onDelete={(id) => { - setCurrId(id) - setShowConfirm(true) - }} - /> - {index === pinnedAppsCount - 1 && index !== installedApps.length - 1 && } - - ))} -
-
- )} - - {!isMobile && ( -
- {isFold - ? +
+ {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

} + {shouldUseExpandedScrollArea + ? ( +
+ + + + {installedAppItems} + + + + + + +
+ ) : ( - +
+ {installedAppItems} +
)}
)} - {showConfirm && ( - setShowConfirm(false)} - /> + {!isMobile && ( +
+
+ {isFold + ? + : ( + + )} +
+
)} + + + +
+ + {t('sidebar.delete.title', { ns: 'explore' })} + + + {t('sidebar.delete.content', { ns: 'explore' })} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('operation.confirm', { ns: 'common' })} + + +
+
) } diff --git a/web/app/components/goto-anything/__tests__/command-selector.spec.tsx b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx index 56e40a71f0..98c6ac784f 100644 --- a/web/app/components/goto-anything/__tests__/command-selector.spec.tsx +++ b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx @@ -5,7 +5,7 @@ import { Command } from 'cmdk' import * as React from 'react' import CommandSelector from '../command-selector' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => '/app', })) diff --git a/web/app/components/goto-anything/__tests__/context.spec.tsx b/web/app/components/goto-anything/__tests__/context.spec.tsx index c427f76c61..70a30786df 100644 --- a/web/app/components/goto-anything/__tests__/context.spec.tsx +++ b/web/app/components/goto-anything/__tests__/context.spec.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { GotoAnythingProvider, useGotoAnythingContext } from '../context' let pathnameMock: string | null | undefined = '/' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: () => pathnameMock, })) diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx index eb5fa8ccdd..b2050ef9fb 100644 --- a/web/app/components/goto-anything/__tests__/index.spec.tsx +++ b/web/app/components/goto-anything/__tests__/index.spec.tsx @@ -11,7 +11,7 @@ type TestSearchResult = Omit & { } const routerPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: routerPush, }), diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx index bdb641cae6..59373c9e3a 100644 --- a/web/app/components/goto-anything/command-selector.tsx +++ b/web/app/components/goto-anything/command-selector.tsx @@ -1,9 +1,9 @@ import type { FC } from 'react' import type { ActionItem } from './actions/types' import { Command } from 'cmdk' -import { usePathname } from 'next/navigation' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { usePathname } from '@/next/navigation' import { slashCommandRegistry } from './actions/commands/registry' type Props = { diff --git a/web/app/components/goto-anything/context.tsx b/web/app/components/goto-anything/context.tsx index 5c2bf3cb6b..28fb08ac17 100644 --- a/web/app/components/goto-anything/context.tsx +++ b/web/app/components/goto-anything/context.tsx @@ -1,9 +1,9 @@ 'use client' import type { ReactNode } from 'react' -import { usePathname } from 'next/navigation' import * as React from 'react' import { createContext, useContext, useEffect, useState } from 'react' +import { usePathname } from '@/next/navigation' import { isInWorkflowPage } from '../workflow/constants' /** diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts index 1ac3bbc17c..c8a6a4a13c 100644 --- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts +++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts @@ -16,7 +16,7 @@ type MockCommandResult = { let mockFindCommandResult: MockCommandResult = null -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockRouterPush, }), diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts index 73be6cd3ee..9c9871fa1d 100644 --- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts +++ b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts @@ -3,9 +3,9 @@ import type { RefObject } from 'react' import type { Plugin } from '../../plugins/types' import type { ActionItem, SearchResult } from '../actions/types' -import { useRouter } from 'next/navigation' import { useCallback, useState } from 'react' import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation' +import { useRouter } from '@/next/navigation' import { slashCommandRegistry } from '../actions/commands/registry' export type UseGotoAnythingNavigationReturn = { diff --git a/web/app/components/header/__tests__/header-wrapper.spec.tsx b/web/app/components/header/__tests__/header-wrapper.spec.tsx index b1948e0992..cdb6a7a849 100644 --- a/web/app/components/header/__tests__/header-wrapper.spec.tsx +++ b/web/app/components/header/__tests__/header-wrapper.spec.tsx @@ -1,10 +1,10 @@ import { act, render, screen } from '@testing-library/react' -import { usePathname } from 'next/navigation' import { vi } from 'vitest' import { useEventEmitterContextContext } from '@/context/event-emitter' +import { usePathname } from '@/next/navigation' import HeaderWrapper from '../header-wrapper' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ usePathname: vi.fn(), })) diff --git a/web/app/components/header/__tests__/index.spec.tsx b/web/app/components/header/__tests__/index.spec.tsx index 93ab7fb535..16e0854339 100644 --- a/web/app/components/header/__tests__/index.spec.tsx +++ b/web/app/components/header/__tests__/index.spec.tsx @@ -52,7 +52,7 @@ vi.mock('@/context/workspace-context-provider', () => ({ WorkspaceProvider: ({ children }: { children?: React.ReactNode }) => children, })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children?: React.ReactNode, href?: string }) => {children}, })) diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx index b80cbb8f03..09ab89fc88 100644 --- a/web/app/components/header/account-about/index.tsx +++ b/web/app/components/header/account-about/index.tsx @@ -2,15 +2,15 @@ import type { LangGeniusVersionResponse } from '@/models/common' import { RiCloseLine } from '@remixicon/react' import dayjs from 'dayjs' -import Link from 'next/link' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import DifyLogo from '@/app/components/base/logo/dify-logo' import Modal from '@/app/components/base/modal' import { IS_CE_EDITION } from '@/config' - import { useGlobalPublicStore } from '@/context/global-public-context' +import Link from '@/next/link' + type IAccountSettingProps = { langGeniusVersionInfo: LangGeniusVersionResponse onCancel: () => void diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index e1d4c45810..eb4d543e66 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -3,12 +3,12 @@ import type { ModalContextState } from '@/context/modal-context' import type { ProviderContextState } from '@/context/provider-context' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { useRouter } from 'next/navigation' import { Plan } from '@/app/components/billing/type' import { useAppContext } from '@/context/app-context' import { useGlobalPublicStore } from '@/context/global-public-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import AppSelector from '../index' @@ -53,8 +53,8 @@ vi.mock('@/service/use-common', () => ({ useLogout: vi.fn(), })) -vi.mock('next/navigation', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@/next/navigation', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, useRouter: vi.fn(), diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 0a5779839e..1697433ac4 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -1,8 +1,6 @@ 'use client' import type { MouseEventHandler, ReactNode } from 'react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' @@ -18,6 +16,8 @@ import { useDocLink } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { env } from '@/env' +import Link from '@/next/link' +import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import { cn } from '@/utils/classnames' import AccountAbout from '../account-about' diff --git a/web/app/components/header/account-setting/Integrations-page/index.tsx b/web/app/components/header/account-setting/Integrations-page/index.tsx index ef234b5db7..29d0d9fcd3 100644 --- a/web/app/components/header/account-setting/Integrations-page/index.tsx +++ b/web/app/components/header/account-setting/Integrations-page/index.tsx @@ -1,7 +1,7 @@ 'use client' -import Link from 'next/link' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import { useAccountIntegrates } from '@/service/use-common' import { cn } from '@/utils/classnames' import s from './index.module.css' diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index 38cbb58a1b..2aa9db4771 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -27,7 +27,7 @@ vi.mock('@/context/app-context', async (importOriginal) => { } }) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn(), diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx index daf9d3b988..a9d81a12e0 100644 --- a/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/install-from-marketplace.spec.tsx @@ -16,7 +16,7 @@ vi.mock('next-themes', () => ({ useTheme: vi.fn(), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => ( {children} ), diff --git a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx index f02e276f55..1a1ca19c3e 100644 --- a/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/data-source-page-new/install-from-marketplace.tsx @@ -4,7 +4,6 @@ import { RiArrowRightUpLine, } from '@remixicon/react' import { useTheme } from 'next-themes' -import Link from 'next/link' import { memo, useCallback, @@ -15,6 +14,7 @@ import Divider from '@/app/components/base/divider' import Loading from '@/app/components/base/loading' import List from '@/app/components/plugins/marketplace/list' import ProviderCard from '@/app/components/plugins/provider-card' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { getMarketplaceUrl } from '@/utils/var' import { diff --git a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx index fb032ebd62..eafd57ed66 100644 --- a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx @@ -61,7 +61,7 @@ vi.mock('@/app/components/base/select', async () => { } }) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ refresh: mockRefresh }), })) diff --git a/web/app/components/header/account-setting/language-page/index.tsx b/web/app/components/header/account-setting/language-page/index.tsx index 5751e88285..6c84a25428 100644 --- a/web/app/components/header/account-setting/language-page/index.tsx +++ b/web/app/components/header/account-setting/language-page/index.tsx @@ -2,7 +2,6 @@ import type { Item } from '@/app/components/base/select' import type { Locale } from '@/i18n-config' -import { useRouter } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' @@ -12,6 +11,7 @@ import { useAppContext } from '@/context/app-context' import { useLocale } from '@/context/i18n' import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' +import { useRouter } from '@/next/navigation' import { updateUserProfile } from '@/service/common' import { timezones } from '@/utils/timezone' diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index c4f614737a..099a146866 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -1,6 +1,6 @@ import { noop } from 'es-toolkit/function' import * as React from 'react' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' import Button from '@/app/components/base/button' @@ -36,18 +36,33 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { const [stepToken, setStepToken] = useState('') const [newOwner, setNewOwner] = useState('') const [isTransfer, setIsTransfer] = useState(false) + const timerIdRef = React.useRef(undefined) + + const retimeCountdown = useCallback((timerId?: number) => { + if (timerIdRef.current !== undefined) + window.clearInterval(timerIdRef.current) + + timerIdRef.current = timerId + }, []) + + React.useEffect(() => { + if (!show) + retimeCountdown() + + return retimeCountdown + }, [retimeCountdown, show]) const startCount = () => { setTime(60) - const timer = setInterval(() => { + retimeCountdown(window.setInterval(() => { setTime((prev) => { - if (prev <= 0) { - clearInterval(timer) + if (prev <= 1) { + retimeCountdown() return 0 } return prev - 1 }) - }, 1000) + }, 1000)) } const sendEmail = async () => { diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx index 452068e61c..68a705e6c4 100644 --- a/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/__tests__/install-from-marketplace.spec.tsx @@ -7,7 +7,7 @@ import { useMarketplaceAllPlugins } from '../hooks' import InstallFromMarketplace from '../install-from-marketplace' // Mock dependencies -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => {children}, })) diff --git a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx index ab712f27cc..289e8ce80e 100644 --- a/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx +++ b/web/app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx @@ -3,13 +3,13 @@ import type { } from './declarations' import type { Plugin } from '@/app/components/plugins/types' import { useTheme } from 'next-themes' -import Link from 'next/link' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import Loading from '@/app/components/base/loading' import List from '@/app/components/plugins/marketplace/list' import ProviderCard from '@/app/components/plugins/provider-card' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { getMarketplaceUrl } from '@/utils/var' import { diff --git a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx index e9780680e2..a6a93acbc2 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-parameter-modal/status-indicators.tsx @@ -1,7 +1,7 @@ import { RiErrorWarningFill } from '@remixicon/react' -import Link from 'next/link' import Tooltip from '@/app/components/base/tooltip' import { SwitchPluginVersion } from '@/app/components/workflow/nodes/_base/components/switch-plugin-version' +import Link from '@/next/link' import { useInstalledPluginList } from '@/service/use-plugins' type StatusIndicatorsProps = { diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index fd3577a7cf..a3f20bed8f 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -43,10 +43,10 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('@/app/components/base/toast/context', () => ({ - useToastContext: () => ({ - notify: mockNotify, - }), +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockNotify, + }, })) vi.mock('../../hooks', () => ({ @@ -150,7 +150,7 @@ describe('SystemModel', () => { expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1) expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - message: 'Modified successfully', + title: 'Modified successfully', }) expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5) expect(mockUpdateModelList).toHaveBeenCalledTimes(5) diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx index a103cafd91..f311d82b57 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx @@ -6,13 +6,13 @@ import type { import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' -import { useToastContext } from '@/app/components/base/toast/context' import { Dialog, DialogCloseButton, DialogContent, DialogTitle, } from '@/app/components/base/ui/dialog' +import { toast } from '@/app/components/base/ui/toast' import { Tooltip, TooltipContent, @@ -64,7 +64,6 @@ const SystemModel: FC = ({ isLoading, }) => { const { t } = useTranslation() - const { notify } = useToastContext() const { isCurrentWorkspaceManager } = useAppContext() const { textGenerationModelList } = useProviderContext() const updateModelList = useUpdateModelList() @@ -124,7 +123,7 @@ const SystemModel: FC = ({ }, }) if (res.result === 'success') { - notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) + toast.add({ type: 'success', title: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) setOpen(false) const allModelTypes = [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank, ModelTypeEnum.speech2text, ModelTypeEnum.tts] diff --git a/web/app/components/header/account-setting/plugin-page/index.tsx b/web/app/components/header/account-setting/plugin-page/index.tsx index beda55f2f2..a71c3ee072 100644 --- a/web/app/components/header/account-setting/plugin-page/index.tsx +++ b/web/app/components/header/account-setting/plugin-page/index.tsx @@ -1,7 +1,7 @@ import type { PluginProvider } from '@/models/common' import { LockClosedIcon } from '@heroicons/react/24/solid' -import Link from 'next/link' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import { usePluginProviders } from '@/service/use-common' import SerpapiPlugin from './SerpapiPlugin' diff --git a/web/app/components/header/app-nav/__tests__/index.spec.tsx b/web/app/components/header/app-nav/__tests__/index.spec.tsx index 0ccb468670..03f8edfacf 100644 --- a/web/app/components/header/app-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/app-nav/__tests__/index.spec.tsx @@ -1,13 +1,13 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useParams } from 'next/navigation' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' +import { useParams } from '@/next/navigation' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import AppNav from '../index' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(), })) diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx index 737dd96bab..214b7612bb 100644 --- a/web/app/components/header/app-nav/index.tsx +++ b/web/app/components/header/app-nav/index.tsx @@ -7,7 +7,6 @@ import { } from '@remixicon/react' import { flatten } from 'es-toolkit/compat' import { produce } from 'immer' -import { useParams } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog' @@ -15,6 +14,7 @@ import CreateAppModal from '@/app/components/app/create-app-modal' import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' +import { useParams } from '@/next/navigation' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum } from '@/types/app' import Nav from '../nav' diff --git a/web/app/components/header/app-selector/__tests__/index.spec.tsx b/web/app/components/header/app-selector/__tests__/index.spec.tsx index 676aba7023..eddb7e52aa 100644 --- a/web/app/components/header/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/app-selector/__tests__/index.spec.tsx @@ -1,12 +1,12 @@ import type { AppDetailResponse } from '@/models/app' import { act, fireEvent, render, screen } from '@testing-library/react' -import { useRouter } from 'next/navigation' import { vi } from 'vitest' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import AppSelector from '../index' // Mock next/navigation -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), })) diff --git a/web/app/components/header/app-selector/index.tsx b/web/app/components/header/app-selector/index.tsx index 13677ef7ab..89f87c2687 100644 --- a/web/app/components/header/app-selector/index.tsx +++ b/web/app/components/header/app-selector/index.tsx @@ -3,12 +3,12 @@ import type { AppDetailResponse } from '@/models/app' import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react' import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid' import { noop } from 'es-toolkit/function' -import { useRouter } from 'next/navigation' import { Fragment, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateAppDialog from '@/app/components/app/create-app-dialog' import AppIcon from '@/app/components/base/app-icon' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import Indicator from '../indicator' type IAppSelectorProps = { diff --git a/web/app/components/header/dataset-nav/__tests__/index.spec.tsx b/web/app/components/header/dataset-nav/__tests__/index.spec.tsx index a551538e98..a81fa0ca3f 100644 --- a/web/app/components/header/dataset-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/dataset-nav/__tests__/index.spec.tsx @@ -1,18 +1,18 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppContext } from '@/context/app-context' import { useParams, useRouter, useSelectedLayoutSegment, -} from 'next/navigation' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useAppContext } from '@/context/app-context' +} from '@/next/navigation' import { useDatasetDetail, useDatasetList, } from '@/service/knowledge/use-dataset' import DatasetNav from '../index' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: vi.fn(), useRouter: vi.fn(), useSelectedLayoutSegment: vi.fn(), diff --git a/web/app/components/header/dataset-nav/index.tsx b/web/app/components/header/dataset-nav/index.tsx index c0e79128e9..0b39560884 100644 --- a/web/app/components/header/dataset-nav/index.tsx +++ b/web/app/components/header/dataset-nav/index.tsx @@ -7,9 +7,9 @@ import { RiBook2Line, } from '@remixicon/react' import { flatten } from 'es-toolkit/compat' -import { useParams, useRouter } from 'next/navigation' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { useParams, useRouter } from '@/next/navigation' import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-dataset' import { basePath } from '@/utils/var' import Nav from '../nav' diff --git a/web/app/components/header/explore-nav/__tests__/index.spec.tsx b/web/app/components/header/explore-nav/__tests__/index.spec.tsx index 79285cf53e..0ef271b034 100644 --- a/web/app/components/header/explore-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/explore-nav/__tests__/index.spec.tsx @@ -1,9 +1,9 @@ import type { Mock } from 'vitest' import { render, screen } from '@testing-library/react' -import { useSelectedLayoutSegment } from 'next/navigation' +import { useSelectedLayoutSegment } from '@/next/navigation' import ExploreNav from '../index' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: vi.fn(), })) diff --git a/web/app/components/header/explore-nav/index.tsx b/web/app/components/header/explore-nav/index.tsx index 34deb61fe7..9931690e83 100644 --- a/web/app/components/header/explore-nav/index.tsx +++ b/web/app/components/header/explore-nav/index.tsx @@ -4,9 +4,9 @@ import { RiPlanetFill, RiPlanetLine, } from '@remixicon/react' -import Link from 'next/link' -import { useSelectedLayoutSegment } from 'next/navigation' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' +import { useSelectedLayoutSegment } from '@/next/navigation' import { cn } from '@/utils/classnames' type ExploreNavProps = { diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx index 1b81c1152c..e140939976 100644 --- a/web/app/components/header/header-wrapper.tsx +++ b/web/app/components/header/header-wrapper.tsx @@ -1,8 +1,8 @@ 'use client' -import { usePathname } from 'next/navigation' import * as React from 'react' import { useState } from 'react' import { useEventEmitterContextContext } from '@/context/event-emitter' +import { usePathname } from '@/next/navigation' import { cn } from '@/utils/classnames' import s from './index.module.css' diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx index 0b86a6259b..cc4dd9bb61 100644 --- a/web/app/components/header/index.tsx +++ b/web/app/components/header/index.tsx @@ -1,5 +1,4 @@ 'use client' -import Link from 'next/link' import { useCallback } from 'react' import DifyLogo from '@/app/components/base/logo/dify-logo' import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector' @@ -10,6 +9,7 @@ import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { WorkspaceProvider } from '@/context/workspace-context-provider' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import Link from '@/next/link' import { Plan } from '../billing/type' import AccountDropdown from './account-dropdown' import AppNav from './app-nav' diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx index a47dc711c8..6ee8a7a924 100644 --- a/web/app/components/header/nav/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/__tests__/index.spec.tsx @@ -7,11 +7,11 @@ import { screen, waitFor, } from '@testing-library/react' -import { useRouter, useSelectedLayoutSegment } from 'next/navigation' import * as React from 'react' import { vi } from 'vitest' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' +import { useRouter, useSelectedLayoutSegment } from '@/next/navigation' import { AppModeEnum } from '@/types/app' import Nav from '../index' @@ -69,7 +69,7 @@ vi.mock('@headlessui/react', () => { }) // Mock next/navigation -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: vi.fn(), useRouter: vi.fn(), })) diff --git a/web/app/components/header/nav/index.tsx b/web/app/components/header/nav/index.tsx index ca4498e4fb..1342bb1fa3 100644 --- a/web/app/components/header/nav/index.tsx +++ b/web/app/components/header/nav/index.tsx @@ -1,12 +1,12 @@ 'use client' import type { INavSelectorProps } from './nav-selector' -import Link from 'next/link' -import { useSelectedLayoutSegment } from 'next/navigation' import * as React from 'react' import { useState } from 'react' import { useStore as useAppStore } from '@/app/components/app/store' import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' +import Link from '@/next/link' +import { useSelectedLayoutSegment } from '@/next/navigation' import { cn } from '@/utils/classnames' import NavSelector from './nav-selector' diff --git a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx index 55d77389c6..152901b79c 100644 --- a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx @@ -1,11 +1,11 @@ import type { INavSelectorProps, NavItem } from '../index' import type { AppContextValue } from '@/context/app-context' import { act, fireEvent, render, screen } from '@testing-library/react' -import { useRouter } from 'next/navigation' import * as React from 'react' import { vi } from 'vitest' import { useStore as useAppStore } from '@/app/components/app/store' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import { AppModeEnum } from '@/types/app' import NavSelector from '../index' @@ -63,7 +63,7 @@ vi.mock('@headlessui/react', () => { }) // Mock next/navigation -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: vi.fn(), })) diff --git a/web/app/components/header/nav/nav-selector/index.tsx b/web/app/components/header/nav/nav-selector/index.tsx index e66837c06c..264bfc0ffb 100644 --- a/web/app/components/header/nav/nav-selector/index.tsx +++ b/web/app/components/header/nav/nav-selector/index.tsx @@ -7,7 +7,6 @@ import { RiArrowRightSLine, } from '@remixicon/react' import { debounce } from 'es-toolkit/compat' -import { useRouter } from 'next/navigation' import { Fragment, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' @@ -16,6 +15,7 @@ import AppIcon from '@/app/components/base/app-icon' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' import Loading from '@/app/components/base/loading' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import { cn } from '@/utils/classnames' export type NavItem = { diff --git a/web/app/components/header/plugins-nav/__tests__/index.spec.tsx b/web/app/components/header/plugins-nav/__tests__/index.spec.tsx index 009e573eb1..ab55225641 100644 --- a/web/app/components/header/plugins-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/plugins-nav/__tests__/index.spec.tsx @@ -1,11 +1,11 @@ import type { Mock } from 'vitest' import { render, screen } from '@testing-library/react' -import { useSelectedLayoutSegment } from 'next/navigation' import { usePluginTaskStatus } from '@/app/components/plugins/plugin-page/plugin-tasks/hooks' +import { useSelectedLayoutSegment } from '@/next/navigation' import PluginsNav from '../index' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: vi.fn(), })) diff --git a/web/app/components/header/plugins-nav/index.tsx b/web/app/components/header/plugins-nav/index.tsx index d806416b3b..c77eb4d57e 100644 --- a/web/app/components/header/plugins-nav/index.tsx +++ b/web/app/components/header/plugins-nav/index.tsx @@ -1,11 +1,11 @@ 'use client' -import Link from 'next/link' -import { useSelectedLayoutSegment } from 'next/navigation' import { useTranslation } from 'react-i18next' import { Group } from '@/app/components/base/icons/src/vender/other' import Indicator from '@/app/components/header/indicator' import { usePluginTaskStatus } from '@/app/components/plugins/plugin-page/plugin-tasks/hooks' +import Link from '@/next/link' +import { useSelectedLayoutSegment } from '@/next/navigation' import { cn } from '@/utils/classnames' import DownloadingIcon from './downloading-icon' diff --git a/web/app/components/header/tools-nav/__tests__/index.spec.tsx b/web/app/components/header/tools-nav/__tests__/index.spec.tsx index e3ceef43a4..361e6f8b84 100644 --- a/web/app/components/header/tools-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/tools-nav/__tests__/index.spec.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import ToolsNav from '../index' const mockUseSelectedLayoutSegment = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSelectedLayoutSegment: () => mockUseSelectedLayoutSegment(), })) diff --git a/web/app/components/header/tools-nav/index.tsx b/web/app/components/header/tools-nav/index.tsx index c8f318a742..141b576a4c 100644 --- a/web/app/components/header/tools-nav/index.tsx +++ b/web/app/components/header/tools-nav/index.tsx @@ -4,9 +4,9 @@ import { RiHammerFill, RiHammerLine, } from '@remixicon/react' -import Link from 'next/link' -import { useSelectedLayoutSegment } from 'next/navigation' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' +import { useSelectedLayoutSegment } from '@/next/navigation' import { cn } from '@/utils/classnames' type ToolsNavProps = { diff --git a/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx index 42616f3138..6b10e4c1f3 100644 --- a/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx +++ b/web/app/components/plugins/base/__tests__/deprecation-notice.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DeprecationNotice from '../deprecation-notice' -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => ( {children} ), diff --git a/web/app/components/plugins/base/deprecation-notice.tsx b/web/app/components/plugins/base/deprecation-notice.tsx index 01b37bc20c..647c98c36c 100644 --- a/web/app/components/plugins/base/deprecation-notice.tsx +++ b/web/app/components/plugins/base/deprecation-notice.tsx @@ -2,10 +2,10 @@ import type { FC } from 'react' import { useTranslation } from '#i18n' import { RiAlertFill } from '@remixicon/react' import { camelCase } from 'es-toolkit/string' -import Link from 'next/link' import * as React from 'react' import { useMemo } from 'react' import { Trans } from 'react-i18next' +import Link from '@/next/link' import { cn } from '@/utils/classnames' type DeprecationNoticeProps = { diff --git a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts index b0e3ec5832..918a9b36e3 100644 --- a/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts +++ b/web/app/components/plugins/install-plugin/__tests__/hooks.spec.ts @@ -16,34 +16,6 @@ vi.mock('@/service/plugins', () => ({ uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args), })) -vi.mock('@/utils/semver', () => ({ - compareVersion: (a: string, b: string) => { - const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number) - const va = parseVersion(a) - const vb = parseVersion(b) - for (let i = 0; i < Math.max(va.length, vb.length); i++) { - const diff = (va[i] || 0) - (vb[i] || 0) - if (diff > 0) - return 1 - if (diff < 0) - return -1 - } - return 0 - }, - getLatestVersion: (versions: string[]) => { - return versions.sort((a, b) => { - const pa = a.replace(/^v/, '').split('.').map(Number) - const pb = b.replace(/^v/, '').split('.').map(Number) - for (let i = 0; i < Math.max(pa.length, pb.length); i++) { - const diff = (pa[i] || 0) - (pb[i] || 0) - if (diff !== 0) - return diff - } - return 0 - }).pop()! - }, -})) - const mockFetch = vi.fn() globalThis.fetch = mockFetch diff --git a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx index 1e36daefc1..d37151a253 100644 --- a/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx @@ -5,12 +5,12 @@ import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { gte } from 'semver' import Button from '@/app/components/base/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { useAppContext } from '@/context/app-context' import { uninstallPlugin } from '@/service/plugins' import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins' +import { isEqualOrLaterThanVersion } from '@/utils/semver' import Card from '../../../card' import { TaskStatus } from '../../../types' import checkTaskStatus from '../../base/check-task-status' @@ -111,13 +111,13 @@ const Installed: FC = ({ const isDifyVersionCompatible = useMemo(() => { if (!langGeniusVersionInfo.current_version) return true - return gte(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0') + return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0') }, [langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version]) return ( <>
-
+

{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}

= ({ />

{!isDifyVersionCompatible && ( -

+

{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: payload.meta.minimum_dify_version })}

)} diff --git a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx index 275d4ca47b..8a4e0bd82a 100644 --- a/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx +++ b/web/app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx @@ -5,11 +5,11 @@ import { RiLoader2Line } from '@remixicon/react' import * as React from 'react' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { gte } from 'semver' import Button from '@/app/components/base/button' import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed' import { useAppContext } from '@/context/app-context' import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins' +import { isEqualOrLaterThanVersion } from '@/utils/semver' import Card from '../../../card' // import { RiInformation2Line } from '@remixicon/react' import { TaskStatus } from '../../../types' @@ -126,17 +126,17 @@ const Installed: FC = ({ const isDifyVersionCompatible = useMemo(() => { if (!pluginDeclaration || !langGeniusVersionInfo.current_version) return true - return gte(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0') + return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0') }, [langGeniusVersionInfo.current_version, pluginDeclaration]) const { canInstall } = useInstallPluginLimit({ ...payload, from: 'marketplace' }) return ( <>
-
+

{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}

{!isDifyVersionCompatible && ( -

+

{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}

)} diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx index 5497786794..4dd604a03e 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/__tests__/index.spec.tsx @@ -76,16 +76,16 @@ afterAll(() => { // Mock portal components for controlled positioning in tests // Use React context to properly scope open state per portal instance (for nested portals) -const _PortalOpenContext = React.createContext(false) - vi.mock('@/app/components/base/portal-to-follow-elem', () => { // Context reference shared across mock components let sharedContext: React.Context | null = null // Lazily get or create the context const getContext = (): React.Context => { - if (!sharedContext) - sharedContext = React.createContext(false) + if (!sharedContext) { + const PortalOpenContext = React.createContext(false) + sharedContext = PortalOpenContext + } return sharedContext } @@ -725,6 +725,39 @@ describe('AppPicker', () => { triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) expect(onLoadMore).toHaveBeenCalledTimes(2) }) + + it('should reset loadingRef when the picker closes before the debounce timeout finishes', () => { + const onLoadMore = vi.fn() + const { rerender } = render( + , + ) + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(1) + + rerender() + rerender() + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(2) + }) + + it('should reset loadingRef when the picker unmounts before the debounce timeout finishes', () => { + const onLoadMore = vi.fn() + const { unmount } = render( + , + ) + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(1) + + unmount() + + render() + + triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) + expect(onLoadMore).toHaveBeenCalledTimes(2) + }) }) describe('Memoization', () => { @@ -1539,7 +1572,7 @@ describe('AppSelector', () => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) - it('should manage isLoadingMore state during load more', () => { + it('should render correctly during load more setup', () => { mockHasNextPage = true mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) @@ -1739,7 +1772,7 @@ describe('AppSelector', () => { expect(mockFetchNextPage).toHaveBeenCalled() }) - it('should set isLoadingMore and reset after delay in handleLoadMore', async () => { + it('should avoid duplicate fetches while the picker debounce is active', async () => { mockHasNextPage = true mockIsFetchingNextPage = false mockFetchNextPage.mockResolvedValue(undefined) @@ -1756,34 +1789,15 @@ describe('AppSelector', () => { expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - // Try to trigger again immediately - should be blocked by isLoadingMore + // Try to trigger again immediately - should be blocked by AppPicker loadingRef triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - // Still only one call due to isLoadingMore + // Still only one call due to the picker-level debounce expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - // This verifies the debounce logic is working - multiple calls are blocked expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0) }) - it('should not call fetchNextPage when isLoadingMore is true', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000))) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // Trigger intersection - this starts loading - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - }) - it('should skip handleLoadMore when isFetchingNextPage is true', async () => { mockHasNextPage = true mockIsFetchingNextPage = true // This will block the handleLoadMore @@ -1821,89 +1835,7 @@ describe('AppSelector', () => { // fetchNextPage should NOT be called because hasMore is false expect(mockFetchNextPage).not.toHaveBeenCalled() }) - - it('should return early from handleLoadMore when isLoadingMore is true', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - // Make fetchNextPage slow to keep isLoadingMore true - mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000))) - - renderWithQueryClient() - - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // First call starts loading - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - - // Second call should return early due to isLoadingMore - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // Still only 1 call because isLoadingMore blocks it - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - }) - - it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // Trigger load more - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // Wait for fetchNextPage to complete and setTimeout to fire - await act(async () => { - await Promise.resolve() - vi.advanceTimersByTime(350) // Past the 300ms setTimeout - }) - - // Should be able to load more again - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - // This might trigger another fetch if loadingRef also reset - expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0) - }) - - it('should reset isLoadingMore after fetchNextPage completes with setTimeout', async () => { - mockHasNextPage = true - mockIsFetchingNextPage = false - mockFetchNextPage.mockResolvedValue(undefined) - - renderWithQueryClient() - - // Open portals - fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - const triggers = screen.getAllByTestId('portal-trigger') - fireEvent.click(triggers[1]) - - // Trigger first intersection - triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry]) - - expect(mockFetchNextPage).toHaveBeenCalledTimes(1) - - // Advance timer past the 300ms setTimeout in finally block - await act(async () => { - vi.advanceTimersByTime(400) - }) - - // Also advance past the loadingRef timeout in AppPicker (500ms) - await act(async () => { - vi.advanceTimersByTime(200) - }) - - // Verify component is still rendered correctly - expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0) - }) }) - describe('Form Change Handling', () => { it('should handle form change with image file', () => { const onSelect = vi.fn() @@ -2284,7 +2216,7 @@ describe('AppSelector Integration', () => { expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) - it('should set isLoadingMore to false after fetchNextPage completes', async () => { + it('should stay stable after fetchNextPage completes', async () => { mockHasNextPage = true mockIsFetchingNextPage = false mockFetchNextPage.mockResolvedValue(undefined) @@ -2293,16 +2225,10 @@ describe('AppSelector Integration', () => { fireEvent.click(screen.getAllByTestId('portal-trigger')[0]) - // Advance timers past the 300ms delay - await act(async () => { - vi.advanceTimersByTime(400) - }) - expect(screen.getByTestId('portal-content')).toBeInTheDocument() }) it('should not call fetchNextPage when conditions prevent it', () => { - // isLoadingMore would be true internally mockHasNextPage = false mockIsFetchingNextPage = true diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx index c32e959652..b849ced8fd 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx @@ -51,9 +51,30 @@ const AppPicker: FC = ({ onSearchChange, }) => { const { t } = useTranslation() - const observerTarget = useRef(null) + const observerTargetRef = useRef(null) const observerRef = useRef(null) const loadingRef = useRef(false) + const loadingResetTimerIdRef = useRef(undefined) + + const retimeLoadingReset = useCallback((timerId?: number) => { + if (loadingResetTimerIdRef.current !== undefined) + globalThis.clearTimeout(loadingResetTimerIdRef.current) + + loadingResetTimerIdRef.current = timerId + }, []) + + const resetLoadingState = useCallback(() => { + retimeLoadingReset() + loadingRef.current = false + }, [retimeLoadingReset]) + + const disconnectObserver = useCallback(() => { + if (!observerRef.current) + return + + observerRef.current.disconnect() + observerRef.current = null + }, []) const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => { const target = entries[0] @@ -62,27 +83,27 @@ const AppPicker: FC = ({ loadingRef.current = true onLoadMore() - // Reset loading state - setTimeout(() => { + retimeLoadingReset(window.setTimeout(() => { loadingRef.current = false - }, 500) - }, [hasMore, isLoading, onLoadMore]) + retimeLoadingReset() + }, 500)) + }, [hasMore, isLoading, onLoadMore, retimeLoadingReset]) useEffect(() => { if (!isShow) { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } + resetLoadingState() + disconnectObserver() return } let mutationObserver: MutationObserver | null = null const setupIntersectionObserver = () => { - if (!observerTarget.current) + if (!observerTargetRef.current) return + disconnectObserver() + // Create new observer observerRef.current = new IntersectionObserver(handleIntersection, { root: null, @@ -90,12 +111,12 @@ const AppPicker: FC = ({ threshold: 0.1, }) - observerRef.current.observe(observerTarget.current) + observerRef.current.observe(observerTargetRef.current) } // Set up MutationObserver to watch DOM changes mutationObserver = new MutationObserver((_mutations) => { - if (observerTarget.current) { + if (observerTargetRef.current) { setupIntersectionObserver() mutationObserver?.disconnect() } @@ -108,17 +129,15 @@ const AppPicker: FC = ({ }) // If element exists, set up IntersectionObserver directly - if (observerTarget.current) + if (observerTargetRef.current) setupIntersectionObserver() return () => { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } + resetLoadingState() + disconnectObserver() mutationObserver?.disconnect() } - }, [isShow, handleIntersection]) + }, [disconnectObserver, handleIntersection, isShow, resetLoadingState]) const getAppType = (app: App) => { switch (app.mode) { @@ -180,7 +199,7 @@ const AppPicker: FC = ({ background={app.icon_background} imageUrl={app.icon_url} /> -
+
{app.name} ( @@ -188,10 +207,10 @@ const AppPicker: FC = ({ )
-
{getAppType(app)}
+
{getAppType(app)}
))} -
+
{isLoading && (
{t('loading', { ns: 'common' })}
diff --git a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx index 5d0fa6d4b8..92960195a4 100644 --- a/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/app-selector/index.tsx @@ -47,9 +47,8 @@ const AppSelector: FC = ({ onSelect, }) => { const { t } = useTranslation() - const [isShow, onShowChange] = useState(false) + const [isShow, setIsShow] = useState(false) const [searchText, setSearchText] = useState('') - const [isLoadingMore, setIsLoadingMore] = useState(false) const { data, @@ -97,25 +96,16 @@ const AppSelector: FC = ({ const hasMore = hasNextPage ?? true const handleLoadMore = useCallback(async () => { - if (isLoadingMore || isFetchingNextPage || !hasMore) + if (isFetchingNextPage || !hasMore) return - setIsLoadingMore(true) - try { - await fetchNextPage() - } - finally { - // Add a small delay to ensure state updates are complete - setTimeout(() => { - setIsLoadingMore(false) - }, 300) - } - }, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage]) + await fetchNextPage() + }, [fetchNextPage, hasMore, isFetchingNextPage]) const handleTriggerClick = () => { if (disabled) return - onShowChange(true) + setIsShow(true) } const [isShowChooseApp, setIsShowChooseApp] = useState(false) @@ -157,7 +147,7 @@ const AppSelector: FC = ({ placement={placement} offset={offset} open={isShow} - onOpenChange={onShowChange} + onOpenChange={setIsShow} > = ({
-
{t('appSelector.label', { ns: 'app' })}
+
{t('appSelector.label', { ns: 'app' })}
= ({ onSelect={handleSelectApp} scope={scope || 'all'} apps={appsForPicker} - isLoading={isLoading || isLoadingMore || isFetchingNextPage} + isLoading={isLoading || isFetchingNextPage} hasMore={hasMore} onLoadMore={handleLoadMore} searchText={searchText} diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx index 2f5dfe4256..af9018694f 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/delete-confirm.spec.tsx @@ -4,7 +4,7 @@ import { DeleteConfirm } from '../delete-confirm' const mockRefetch = vi.fn() const mockDelete = vi.fn() -const mockToast = vi.fn() +const mockToastAdd = vi.hoisted(() => vi.fn()) vi.mock('../use-subscription-list', () => ({ useSubscriptionList: () => ({ refetch: mockRefetch }), @@ -14,9 +14,9 @@ vi.mock('@/service/use-triggers', () => ({ useDeleteTriggerSubscription: () => ({ mutate: mockDelete, isPending: false }), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: (args: { type: string, message: string }) => mockToast(args), +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockToastAdd, }, })) @@ -42,7 +42,7 @@ describe('DeleteConfirm', () => { fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) expect(mockDelete).not.toHaveBeenCalled() - expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) }) it('should allow deletion after matching input name', () => { @@ -87,6 +87,6 @@ describe('DeleteConfirm', () => { fireEvent.click(screen.getByRole('button', { name: /pluginTrigger\.subscription\.list\.item\.actions\.deleteConfirm\.confirm/ })) - expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', message: 'network error' })) + expect(mockToastAdd).toHaveBeenCalledWith(expect.objectContaining({ type: 'error', title: 'network error' })) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx index 4cf8362b26..0c5fff0b82 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx @@ -1,8 +1,16 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Confirm from '@/app/components/base/confirm' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@/app/components/base/ui/alert-dialog' +import { toast } from '@/app/components/base/ui/toast' import { useDeleteTriggerSubscription } from '@/service/use-triggers' import { useSubscriptionList } from './use-subscription-list' @@ -23,58 +31,74 @@ export const DeleteConfirm = (props: Props) => { const { t } = useTranslation() const [inputName, setInputName] = useState('') + const handleOpenChange = (open: boolean) => { + if (isDeleting) + return + + if (!open) + onClose(false) + } + const onConfirm = () => { if (workflowsInUse > 0 && inputName !== currentName) { - Toast.notify({ + toast.add({ type: 'error', - message: t(`${tPrefix}.confirmInputWarning`, { ns: 'pluginTrigger' }), - // temporarily - className: 'z-[10000001]', + title: t(`${tPrefix}.confirmInputWarning`, { ns: 'pluginTrigger' }), }) return } deleteSubscription(currentId, { onSuccess: () => { - Toast.notify({ + toast.add({ type: 'success', - message: t(`${tPrefix}.success`, { ns: 'pluginTrigger', name: currentName }), - className: 'z-[10000001]', + title: t(`${tPrefix}.success`, { ns: 'pluginTrigger', name: currentName }), }) refetch?.() onClose(true) }, - onError: (error: any) => { - Toast.notify({ + onError: (error: unknown) => { + toast.add({ type: 'error', - message: error?.message || t(`${tPrefix}.error`, { ns: 'pluginTrigger', name: currentName }), - className: 'z-[10000001]', + title: error instanceof Error ? error.message : t(`${tPrefix}.error`, { ns: 'pluginTrigger', name: currentName }), }) }, }) } + return ( - 0 - ? ( - <> - {t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse })} -
{t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })}
+ + +
+ + {t(`${tPrefix}.title`, { ns: 'pluginTrigger', name: currentName })} + + + {workflowsInUse > 0 + ? t(`${tPrefix}.contentWithApps`, { ns: 'pluginTrigger', count: workflowsInUse }) + : t(`${tPrefix}.content`, { ns: 'pluginTrigger' })} + + {workflowsInUse > 0 && ( +
+
+ {t(`${tPrefix}.confirmInputTip`, { ns: 'pluginTrigger', name: currentName })} +
setInputName(e.target.value)} placeholder={t(`${tPrefix}.confirmInputPlaceholder`, { ns: 'pluginTrigger', name: currentName })} /> - - ) - : t(`${tPrefix}.content`, { ns: 'pluginTrigger' })} - isShow={isShow} - isLoading={isDeleting} - isDisabled={isDeleting} - onConfirm={onConfirm} - onCancel={() => onClose(false)} - maskClosable={false} - /> +
+ )} +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t(`${tPrefix}.confirm`, { ns: 'pluginTrigger' })} + + +
+
) } diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx index b1664eee97..633f566f79 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/index.tsx @@ -7,7 +7,6 @@ import type { FC } from 'react' import type { Node } from 'reactflow' import type { ToolValue } from '@/app/components/workflow/block-selector/types' import type { NodeOutPutVar } from '@/app/components/workflow/types' -import Link from 'next/link' import * as React from 'react' import { useTranslation } from 'react-i18next' import { @@ -16,6 +15,7 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import { CollectionType } from '@/app/components/tools/types' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { ToolAuthorizationSection, diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx index 3f658c63a8..08da055bde 100644 --- a/web/app/components/plugins/plugin-item/index.tsx +++ b/web/app/components/plugins/plugin-item/index.tsx @@ -11,7 +11,6 @@ import { import * as React from 'react' import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { gte } from 'semver' import Tooltip from '@/app/components/base/tooltip' import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list' import { API_PREFIX } from '@/config' @@ -20,6 +19,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useRenderI18nObject } from '@/hooks/use-i18n' import useTheme from '@/hooks/use-theme' import { cn } from '@/utils/classnames' +import { isEqualOrLaterThanVersion } from '@/utils/semver' import { getMarketplaceUrl } from '@/utils/var' import Badge from '../../base/badge' import { Github } from '../../base/icons/src/public/common' @@ -71,7 +71,7 @@ const PluginItem: FC = ({ const isDifyVersionCompatible = useMemo(() => { if (!langGeniusVersionInfo.current_version) return true - return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') + return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0') }, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version]) const isDeprecated = useMemo(() => { @@ -164,8 +164,8 @@ const PluginItem: FC = ({ /> {category === PluginCategoryEnum.extension && ( <> -
·
-
+
·
+
= ({ && ( <> -
{t('from', { ns: 'plugin' })}
+
{t('from', { ns: 'plugin' })}
GitHub
@@ -196,7 +196,7 @@ const PluginItem: FC = ({ && ( <>
-
+
{t('from', { ns: 'plugin' })} {' '} marketplace @@ -210,7 +210,7 @@ const PluginItem: FC = ({ <>
-
Local Plugin
+
Local Plugin
)} @@ -219,14 +219,14 @@ const PluginItem: FC = ({ <>
-
Debugging Plugin
+
Debugging Plugin
)}
{/* Deprecated */} {source === PluginSource.marketplace && enable_marketplace && isDeprecated && ( -
+
· {t('deprecated', { ns: 'plugin' })} diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx index 6768361acf..78d590f409 100644 --- a/web/app/components/plugins/plugin-page/index.tsx +++ b/web/app/components/plugins/plugin-page/index.tsx @@ -9,7 +9,6 @@ import { } from '@remixicon/react' import { useBoolean } from 'ahooks' import { noop } from 'es-toolkit/function' -import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -21,6 +20,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' import { usePluginInstallation } from '@/hooks/use-query-params' +import Link from '@/next/link' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' import { sleep } from '@/utils' import { cn } from '@/utils/classnames' diff --git a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx index 73fb132850..656bb042a4 100644 --- a/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx +++ b/web/app/components/plugins/update-plugin/__tests__/index.spec.tsx @@ -104,20 +104,6 @@ vi.mock('../../install-plugin/install-from-github', () => ({ ), })) -// Mock semver -vi.mock('semver', () => ({ - lt: (v1: string, v2: string) => { - const parseVersion = (v: string) => v.split('.').map(Number) - const [major1, minor1, patch1] = parseVersion(v1) - const [major2, minor2, patch2] = parseVersion(v2) - if (major1 !== major2) - return major1 < major2 - if (minor1 !== minor2) - return minor1 < minor2 - return patch1 < patch2 - }, -})) - // ================================ // Test Data Factories // ================================ diff --git a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx index d662c2b6e0..9f14cd6c83 100644 --- a/web/app/components/plugins/update-plugin/plugin-version-picker.tsx +++ b/web/app/components/plugins/update-plugin/plugin-version-picker.tsx @@ -4,7 +4,6 @@ import type { Placement } from '@/app/components/base/ui/placement' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import { lt } from 'semver' import Badge from '@/app/components/base/badge' import { Popover, @@ -14,6 +13,7 @@ import { import useTimestamp from '@/hooks/use-timestamp' import { useVersionListOfPlugin } from '@/service/use-plugins' import { cn } from '@/utils/classnames' +import { isEarlierThanVersion } from '@/utils/semver' type Props = { disabled?: boolean @@ -100,7 +100,7 @@ const PluginVersionPicker: FC = ({ onClick={() => handleSelect({ version: version.version, unique_identifier: version.unique_identifier, - isDowngrade: lt(version.version, currentVersion), + isDowngrade: isEarlierThanVersion(version.version, currentVersion), })} >
diff --git a/web/app/components/plugins/utils.ts b/web/app/components/plugins/utils.ts index 1cf6dead97..687e11360e 100644 --- a/web/app/components/plugins/utils.ts +++ b/web/app/components/plugins/utils.ts @@ -21,12 +21,15 @@ const hasUrlProtocol = (value: string) => /^[a-z][a-z\d+.-]*:/i.test(value) export const getPluginCardIconUrl = ( plugin: Pick, - icon: string | undefined, + icon: string | { content: string, background: string } | undefined, tenantId: string, ) => { if (!icon) return '' + if (typeof icon === 'object') + return icon + if (hasUrlProtocol(icon) || icon.startsWith('/')) return icon diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx index 2bd20fb5c3..c7ddf4711e 100644 --- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx @@ -5,7 +5,7 @@ import Conversion from '../conversion' const mockConvert = vi.fn() const mockInvalidDatasetDetail = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'ds-123' }), })) diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 8974965274..36454d33e4 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -10,7 +10,7 @@ import RagPipelineChildren from '../rag-pipeline-children' import PipelineScreenShot from '../screenshot' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index db3c04e9bf..b433359eeb 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -1,10 +1,10 @@ -import { useParams } from 'next/navigation' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Confirm from '@/app/components/base/confirm' import Toast from '@/app/components/base/toast' +import { useParams } from '@/next/navigation' import { datasetDetailQueryKeyPrefix } from '@/service/knowledge/use-dataset' import { useInvalid } from '@/service/use-base' import { useConvertDatasetToPipeline } from '@/service/use-pipeline' diff --git a/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx index f651b16697..8bf870a344 100644 --- a/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx @@ -56,7 +56,7 @@ const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => { return { dynamicMocks: { createMockComponent }, mockInputFieldEditorProps } }) -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record) => { return dynamicMocks.createMockComponent() }, diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx index 00c989acb0..f30db875b3 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx @@ -77,12 +77,12 @@ vi.mock('@/app/components/workflow/header', () => ({ })) const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: PropsWithChildren<{ href: string }>) => ( {children} ), diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index 9cd1af2736..f0b187c0fd 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -8,12 +8,12 @@ import Publisher from '../index' import Popup from '../popup' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'test-dataset-id' }), useRouter: () => ({ push: mockPush }), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, ...props }: { children: React.ReactNode, href: string }) => ( {children} ), diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 48282820d8..5d053f083a 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -19,12 +19,12 @@ 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 -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useParams: () => ({ datasetId: 'ds-123' }), useRouter: () => ({ push: mockPush }), })) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href }: { children: React.ReactNode, href: string }) => ( {children} ), diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index c084a5d45d..6670a8f767 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -10,8 +10,6 @@ import { useBoolean, useKeyPress, } from 'ahooks' -import Link from 'next/link' -import { useParams, useRouter } from 'next/navigation' import { memo, useCallback, @@ -40,6 +38,8 @@ import { useModalContextSelector } from '@/context/modal-context' import { useProviderContextSelector } from '@/context/provider-context' import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' +import Link from '@/next/link' +import { useParams, useRouter } from '@/next/navigation' import { useInvalidDatasetList } from '@/service/knowledge/use-dataset' import { useInvalid } from '@/service/use-base' import { diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx index 46d229c6b6..7ccd788cb0 100644 --- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx +++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx @@ -5,7 +5,7 @@ import MenuDropdown from '../menu-dropdown' const mockReplace = vi.fn() const mockPathname = '/test-path' -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ replace: mockReplace, }), diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 779358bfc6..6162e2a87b 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -4,12 +4,12 @@ import type { InputValueTypes, TextGenerationRunControl } from './types' import type { InstalledApp } from '@/models/explore' import type { VisionFile } from '@/types/app' import { useBoolean } from 'ahooks' -import { useSearchParams } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import Toast from '@/app/components/base/toast' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { useSearchParams } from '@/next/navigation' import { cn } from '@/utils/classnames' import { useTextGenerationAppState } from './hooks/use-text-generation-app-state' import { useTextGenerationBatch } from './hooks/use-text-generation-batch' diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx index 43856a0e24..49633890d3 100644 --- a/web/app/components/share/text-generation/menu-dropdown.tsx +++ b/web/app/components/share/text-generation/menu-dropdown.tsx @@ -5,7 +5,6 @@ import type { SiteInfo } from '@/models/share' import { RiEqualizer2Line, } from '@remixicon/react' -import { usePathname, useRouter } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -18,6 +17,7 @@ import { import ThemeSwitcher from '@/app/components/base/theme-switcher' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' +import { usePathname, useRouter } from '@/next/navigation' import { webAppLogout } from '@/service/webapp-auth' import { cn } from '@/utils/classnames' import Divider from '../../base/divider' diff --git a/web/app/components/tools/provider/empty.tsx b/web/app/components/tools/provider/empty.tsx index 3b9748dc27..725b171deb 100644 --- a/web/app/components/tools/provider/empty.tsx +++ b/web/app/components/tools/provider/empty.tsx @@ -1,8 +1,8 @@ 'use client' import { RiArrowRightUpLine } from '@remixicon/react' -import Link from 'next/link' import { useTranslation } from 'react-i18next' import useTheme from '@/hooks/use-theme' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { NoToolPlaceholder } from '../../base/icons/src/vender/other' import { ToolTypeEnum } from '../../workflow/block-selector/types' diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx index e3bdb4e58a..9cd66e37ea 100644 --- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx +++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx @@ -11,7 +11,7 @@ import MethodSelector from '../method-selector' // Mock Next.js navigation const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, replace: vi.fn(), diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts index cf685a7590..ad0dd2eff2 100644 --- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts +++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts @@ -5,7 +5,7 @@ import { InputVarType } from '@/app/components/workflow/types' import { isParametersOutdated, useConfigureButton } from '../use-configure-button' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), })) diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts index 1aa968ddb1..701ae8fd01 100644 --- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts +++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts @@ -1,11 +1,11 @@ import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' import type { InputVar, Variable } from '@/app/components/workflow/types' import type { PublishWorkflowParams } from '@/types/workflow' -import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Toast from '@/app/components/base/toast' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools' import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools' diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index ae4a21f5a0..de110f2525 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -4,7 +4,6 @@ import type { IOtherOptions } from '@/service/base' import type { VersionHistory } from '@/types/workflow' import { noop } from 'es-toolkit/function' import { produce } from 'immer' -import { usePathname } from 'next/navigation' import { useCallback, useRef } from 'react' import { useReactFlow, @@ -21,6 +20,7 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow- import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' import { useWorkflowStore } from '@/app/components/workflow/store' import { WorkflowRunningStatus } from '@/app/components/workflow/types' +import { usePathname } from '@/next/navigation' import { handleStream, post, sseGet, ssePost } from '@/service/base' import { ContentType } from '@/service/fetch' import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow' diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index 6a778ab6b8..0e8731869f 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -2,7 +2,6 @@ import type { Features as FeaturesData } from '@/app/components/base/features/types' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' -import { useSearchParams } from 'next/navigation' import { useEffect, useMemo, @@ -25,6 +24,7 @@ import { initialNodes, } from '@/app/components/workflow/utils' import { useAppContext } from '@/context/app-context' +import { useSearchParams } from '@/next/navigation' import { fetchRunDetail } from '@/service/log' import { useAppTriggers } from '@/service/use-tools' import { AppModeEnum } from '@/types/app' diff --git a/web/app/components/workflow/__tests__/candidate-node.spec.tsx b/web/app/components/workflow/__tests__/candidate-node.spec.tsx new file mode 100644 index 0000000000..3844bef7ab --- /dev/null +++ b/web/app/components/workflow/__tests__/candidate-node.spec.tsx @@ -0,0 +1,40 @@ +import type { Node } from '../types' +import { screen } from '@testing-library/react' +import CandidateNode from '../candidate-node' +import { BlockEnum } from '../types' +import { renderWorkflowComponent } from './workflow-test-env' + +vi.mock('../candidate-node-main', () => ({ + default: ({ candidateNode }: { candidateNode: Node }) => ( +
{candidateNode.id}
+ ), +})) + +const createCandidateNode = (): Node => ({ + id: 'candidate-node-1', + type: 'custom', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.Start, + title: 'Candidate node', + desc: 'candidate', + }, +}) + +describe('CandidateNode', () => { + it('should not render when candidateNode is missing from the workflow store', () => { + renderWorkflowComponent() + + expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument() + }) + + it('should render CandidateNodeMain with the stored candidate node', () => { + renderWorkflowComponent(, { + initialStoreState: { + candidateNode: createCandidateNode(), + }, + }) + + expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1') + }) +}) diff --git a/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx b/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx new file mode 100644 index 0000000000..aaaf18153d --- /dev/null +++ b/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx @@ -0,0 +1,81 @@ +import type { ComponentProps } from 'react' +import { render } from '@testing-library/react' +import { getBezierPath, Position } from 'reactflow' +import CustomConnectionLine from '../custom-connection-line' + +const createConnectionLineProps = ( + overrides: Partial> = {}, +): ComponentProps => ({ + fromX: 10, + fromY: 20, + toX: 70, + toY: 80, + fromPosition: Position.Right, + toPosition: Position.Left, + connectionLineType: undefined, + connectionStatus: null, + ...overrides, +} as ComponentProps) + +describe('CustomConnectionLine', () => { + it('should render the bezier path and target marker', () => { + const [expectedPath] = getBezierPath({ + sourceX: 10, + sourceY: 20, + sourcePosition: Position.Right, + targetX: 70, + targetY: 80, + targetPosition: Position.Left, + curvature: 0.16, + }) + + const { container } = render( + + + , + ) + + const path = container.querySelector('path') + const marker = container.querySelector('rect') + + expect(path).toHaveAttribute('fill', 'none') + expect(path).toHaveAttribute('stroke', '#D0D5DD') + expect(path).toHaveAttribute('stroke-width', '2') + expect(path).toHaveAttribute('d', expectedPath) + + expect(marker).toHaveAttribute('x', '70') + expect(marker).toHaveAttribute('y', '76') + expect(marker).toHaveAttribute('width', '2') + expect(marker).toHaveAttribute('height', '8') + expect(marker).toHaveAttribute('fill', '#2970FF') + }) + + it('should update the path when the endpoints change', () => { + const [expectedPath] = getBezierPath({ + sourceX: 30, + sourceY: 40, + sourcePosition: Position.Right, + targetX: 160, + targetY: 200, + targetPosition: Position.Left, + curvature: 0.16, + }) + + const { container } = render( + + + , + ) + + expect(container.querySelector('path')).toHaveAttribute('d', expectedPath) + expect(container.querySelector('rect')).toHaveAttribute('x', '160') + expect(container.querySelector('rect')).toHaveAttribute('y', '196') + }) +}) diff --git a/web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx b/web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx new file mode 100644 index 0000000000..e962923158 --- /dev/null +++ b/web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx @@ -0,0 +1,57 @@ +import { render } from '@testing-library/react' +import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render' + +describe('CustomEdgeLinearGradientRender', () => { + it('should render gradient definition with the provided id and positions', () => { + const { container } = render( + + + , + ) + + const gradient = container.querySelector('linearGradient') + expect(gradient).toHaveAttribute('id', 'edge-gradient') + expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse') + expect(gradient).toHaveAttribute('x1', '10') + expect(gradient).toHaveAttribute('y1', '20') + expect(gradient).toHaveAttribute('x2', '30') + expect(gradient).toHaveAttribute('y2', '40') + }) + + it('should render start and stop colors at both ends of the gradient', () => { + const { container } = render( + + + , + ) + + const stops = container.querySelectorAll('stop') + expect(stops).toHaveLength(2) + expect(stops[0]).toHaveAttribute('offset', '0%') + expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)') + expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1') + expect(stops[1]).toHaveAttribute('offset', '100%') + expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)') + expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1') + }) +}) diff --git a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx new file mode 100644 index 0000000000..1e0ba380cd --- /dev/null +++ b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DSLExportConfirmModal from '../dsl-export-confirm-modal' + +const envList = [ + { + id: 'env-1', + name: 'SECRET_TOKEN', + value: 'masked-value', + value_type: 'secret' as const, + description: 'secret token', + }, +] + +const multiEnvList = [ + ...envList, + { + id: 'env-2', + name: 'SERVICE_KEY', + value: 'another-secret', + value_type: 'secret' as const, + description: 'service key', + }, +] + +describe('DSLExportConfirmModal', () => { + it('should render environment rows and close when cancel is clicked', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument() + expect(screen.getByText('masked-value')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('should confirm with exportSecrets=false by default', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' })) + + expect(onConfirm).toHaveBeenCalledWith(false) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should confirm with exportSecrets=true after toggling the checkbox', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + await user.click(screen.getByRole('checkbox')) + await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' })) + + expect(onConfirm).toHaveBeenCalledWith(true) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should also toggle exportSecrets when the label text is clicked', async () => { + const user = userEvent.setup() + const onConfirm = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + await user.click(screen.getByText('workflow.env.export.checkbox')) + await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' })) + + expect(onConfirm).toHaveBeenCalledWith(true) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should render border separators for all rows except the last one', () => { + render( + , + ) + + const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td') + const lastNameCell = screen.getByText('SERVICE_KEY').closest('td') + const firstValueCell = screen.getByText('masked-value').closest('td') + const lastValueCell = screen.getByText('another-secret').closest('td') + + expect(firstNameCell).toHaveClass('border-b') + expect(firstValueCell).toHaveClass('border-b') + expect(lastNameCell).not.toHaveClass('border-b') + expect(lastValueCell).not.toHaveClass('border-b') + }) +}) diff --git a/web/app/components/workflow/__tests__/features.spec.tsx b/web/app/components/workflow/__tests__/features.spec.tsx new file mode 100644 index 0000000000..d7e2cb13ae --- /dev/null +++ b/web/app/components/workflow/__tests__/features.spec.tsx @@ -0,0 +1,193 @@ +import type { InputVar } from '../types' +import type { PromptVariable } from '@/models/debug' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow' +import Features from '../features' +import { InputVarType } from '../types' +import { createStartNode } from './fixtures' +import { renderWorkflowComponent } from './workflow-test-env' + +const mockHandleSyncWorkflowDraft = vi.fn() +const mockHandleAddVariable = vi.fn() + +let mockIsChatMode = true +let mockNodesReadOnly = false + +vi.mock('../hooks', async () => { + const actual = await vi.importActual('../hooks') + return { + ...actual, + useIsChatMode: () => mockIsChatMode, + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + }), + useNodesSyncDraft: () => ({ + handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft, + }), + } +}) + +vi.mock('../nodes/start/use-config', () => ({ + default: () => ({ + handleAddVariable: mockHandleAddVariable, + }), +})) + +vi.mock('@/app/components/base/features/new-feature-panel', () => ({ + default: ({ + show, + isChatMode, + disabled, + onChange, + onClose, + onAutoAddPromptVariable, + workflowVariables, + }: { + show: boolean + isChatMode: boolean + disabled: boolean + onChange: () => void + onClose: () => void + onAutoAddPromptVariable: (variables: PromptVariable[]) => void + workflowVariables: InputVar[] + }) => { + if (!show) + return null + + return ( +
+
{isChatMode ? 'chat mode' : 'completion mode'}
+
{disabled ? 'panel disabled' : 'panel enabled'}
+
    + {workflowVariables.map(variable => ( +
  • + {`${variable.label}:${variable.variable}`} +
  • + ))} +
+ + + + +
+ ) + }, +})) + +const startNode = createStartNode({ + id: 'start-node', + data: { + variables: [{ variable: 'existing_variable', label: 'Existing Variable' }], + }, +}) + +const DelayedFeatures = () => { + const nodes = useNodes() + + if (!nodes.length) + return null + + return +} + +const renderFeatures = (options?: Parameters[1]) => { + return renderWorkflowComponent( +
+ + + + +
, + options, + ) +} + +describe('Features', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsChatMode = true + mockNodesReadOnly = false + }) + + describe('Rendering', () => { + it('should pass workflow context to the feature panel', () => { + renderFeatures() + + expect(screen.getByText('chat mode')).toBeInTheDocument() + expect(screen.getByText('panel enabled')).toBeInTheDocument() + expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable') + }) + }) + + describe('User Interactions', () => { + it('should sync the draft and open the workflow feature panel when users change features', async () => { + const user = userEvent.setup() + const { store } = renderFeatures() + + await user.click(screen.getByRole('button', { name: 'open features' })) + + expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1) + expect(store.getState().showFeaturesPanel).toBe(true) + }) + + it('should close the workflow feature panel and transform required prompt variables', async () => { + const user = userEvent.setup() + const { store } = renderFeatures({ + initialStoreState: { + showFeaturesPanel: true, + }, + }) + + await user.click(screen.getByRole('button', { name: 'close features' })) + expect(store.getState().showFeaturesPanel).toBe(false) + + await user.click(screen.getByRole('button', { name: 'add required variable' })) + expect(mockHandleAddVariable).toHaveBeenCalledWith({ + variable: 'opening_statement', + label: 'Opening Statement', + type: InputVarType.textInput, + max_length: 200, + required: true, + options: [], + }) + }) + + it('should default prompt variables to optional when required is omitted', async () => { + const user = userEvent.setup() + + renderFeatures() + + await user.click(screen.getByRole('button', { name: 'add optional variable' })) + expect(mockHandleAddVariable).toHaveBeenCalledWith({ + variable: 'optional_statement', + label: 'Optional Statement', + type: InputVarType.textInput, + max_length: 120, + required: false, + options: [], + }) + }) + }) +}) diff --git a/web/app/components/workflow/__tests__/reactflow-mock-state.ts b/web/app/components/workflow/__tests__/reactflow-mock-state.ts index dd7a73d2a9..a90bdbaed1 100644 --- a/web/app/components/workflow/__tests__/reactflow-mock-state.ts +++ b/web/app/components/workflow/__tests__/reactflow-mock-state.ts @@ -16,8 +16,8 @@ import * as React from 'react' type MockNode = { id: string position: { x: number, y: number } - width?: number - height?: number + width?: number | null + height?: number | null parentId?: string data: Record } diff --git a/web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx b/web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx new file mode 100644 index 0000000000..6805037d51 --- /dev/null +++ b/web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx @@ -0,0 +1,22 @@ +import SyncingDataModal from '../syncing-data-modal' +import { renderWorkflowComponent } from './workflow-test-env' + +describe('SyncingDataModal', () => { + it('should not render when workflow draft syncing is disabled', () => { + const { container } = renderWorkflowComponent() + + expect(container).toBeEmptyDOMElement() + }) + + it('should render the fullscreen overlay when workflow draft syncing is enabled', () => { + const { container } = renderWorkflowComponent(, { + initialStoreState: { + isSyncingWorkflowDraft: true, + }, + }) + + const overlay = container.firstElementChild + expect(overlay).toHaveClass('absolute', 'inset-0') + expect(overlay).toHaveClass('z-[9999]') + }) +}) diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx index 1e40ea65da..44bd1ea775 100644 --- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx +++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx @@ -80,7 +80,7 @@ function createMouseEvent() { } as unknown as React.MouseEvent } -vi.mock('next/dynamic', () => ({ +vi.mock('@/next/dynamic', () => ({ default: () => () => null, })) diff --git a/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts b/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts new file mode 100644 index 0000000000..a31d6035db --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts @@ -0,0 +1,108 @@ +import type * as React from 'react' +import { act, renderHook } from '@testing-library/react' +import useCheckVerticalScrollbar from '../use-check-vertical-scrollbar' + +const resizeObserve = vi.fn() +const resizeDisconnect = vi.fn() +const mutationObserve = vi.fn() +const mutationDisconnect = vi.fn() + +let resizeCallback: ResizeObserverCallback | null = null +let mutationCallback: MutationCallback | null = null + +class MockResizeObserver implements ResizeObserver { + observe = resizeObserve + unobserve = vi.fn() + disconnect = resizeDisconnect + + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback + } +} + +class MockMutationObserver implements MutationObserver { + observe = mutationObserve + disconnect = mutationDisconnect + takeRecords = vi.fn(() => []) + + constructor(callback: MutationCallback) { + mutationCallback = callback + } +} + +const setElementHeights = (element: HTMLElement, scrollHeight: number, clientHeight: number) => { + Object.defineProperty(element, 'scrollHeight', { + configurable: true, + value: scrollHeight, + }) + Object.defineProperty(element, 'clientHeight', { + configurable: true, + value: clientHeight, + }) +} + +describe('useCheckVerticalScrollbar', () => { + beforeEach(() => { + vi.clearAllMocks() + resizeCallback = null + mutationCallback = null + vi.stubGlobal('ResizeObserver', MockResizeObserver) + vi.stubGlobal('MutationObserver', MockMutationObserver) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should return false when the element ref is empty', () => { + const ref = { current: null } as React.RefObject + + const { result } = renderHook(() => useCheckVerticalScrollbar(ref)) + + expect(result.current).toBe(false) + expect(resizeObserve).not.toHaveBeenCalled() + expect(mutationObserve).not.toHaveBeenCalled() + }) + + it('should detect the initial scrollbar state and react to observer updates', () => { + const element = document.createElement('div') + setElementHeights(element, 200, 100) + const ref = { current: element } as React.RefObject + + const { result } = renderHook(() => useCheckVerticalScrollbar(ref)) + + expect(result.current).toBe(true) + expect(resizeObserve).toHaveBeenCalledWith(element) + expect(mutationObserve).toHaveBeenCalledWith(element, { + childList: true, + subtree: true, + characterData: true, + }) + + setElementHeights(element, 100, 100) + act(() => { + resizeCallback?.([] as ResizeObserverEntry[], new MockResizeObserver(() => {})) + }) + + expect(result.current).toBe(false) + + setElementHeights(element, 180, 100) + act(() => { + mutationCallback?.([] as MutationRecord[], new MockMutationObserver(() => {})) + }) + + expect(result.current).toBe(true) + }) + + it('should disconnect observers on unmount', () => { + const element = document.createElement('div') + setElementHeights(element, 120, 100) + const ref = { current: element } as React.RefObject + + const { unmount } = renderHook(() => useCheckVerticalScrollbar(ref)) + unmount() + + expect(resizeDisconnect).toHaveBeenCalledTimes(1) + expect(mutationDisconnect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts b/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts new file mode 100644 index 0000000000..5949a74682 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts @@ -0,0 +1,103 @@ +import type * as React from 'react' +import { act, renderHook } from '@testing-library/react' +import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll' + +const setRect = (element: HTMLElement, top: number, height: number) => { + element.getBoundingClientRect = vi.fn(() => new DOMRect(0, top, 100, height)) +} + +describe('useStickyScroll', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const runScroll = (handleScroll: () => void) => { + act(() => { + handleScroll() + vi.advanceTimersByTime(120) + }) + } + + it('should keep the default state when refs are missing', () => { + const wrapElemRef = { current: null } as React.RefObject + const nextToStickyELemRef = { current: null } as React.RefObject + + const { result } = renderHook(() => + useStickyScroll({ + wrapElemRef, + nextToStickyELemRef, + }), + ) + + runScroll(result.current.handleScroll) + + expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap) + }) + + it('should mark the sticky element as below the wrapper when it is outside the visible area', () => { + const wrapElement = document.createElement('div') + const nextElement = document.createElement('div') + setRect(wrapElement, 100, 200) + setRect(nextElement, 320, 20) + + const wrapElemRef = { current: wrapElement } as React.RefObject + const nextToStickyELemRef = { current: nextElement } as React.RefObject + + const { result } = renderHook(() => + useStickyScroll({ + wrapElemRef, + nextToStickyELemRef, + }), + ) + + runScroll(result.current.handleScroll) + + expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap) + }) + + it('should mark the sticky element as showing when it is within the wrapper', () => { + const wrapElement = document.createElement('div') + const nextElement = document.createElement('div') + setRect(wrapElement, 100, 200) + setRect(nextElement, 220, 20) + + const wrapElemRef = { current: wrapElement } as React.RefObject + const nextToStickyELemRef = { current: nextElement } as React.RefObject + + const { result } = renderHook(() => + useStickyScroll({ + wrapElemRef, + nextToStickyELemRef, + }), + ) + + runScroll(result.current.handleScroll) + + expect(result.current.scrollPosition).toBe(ScrollPosition.showing) + }) + + it('should mark the sticky element as above the wrapper when it has scrolled past the top', () => { + const wrapElement = document.createElement('div') + const nextElement = document.createElement('div') + setRect(wrapElement, 100, 200) + setRect(nextElement, 90, 20) + + const wrapElemRef = { current: wrapElement } as React.RefObject + const nextToStickyELemRef = { current: nextElement } as React.RefObject + + const { result } = renderHook(() => + useStickyScroll({ + wrapElemRef, + nextToStickyELemRef, + }), + ) + + runScroll(result.current.handleScroll) + + expect(result.current.scrollPosition).toBe(ScrollPosition.aboveTheWrap) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/utils.spec.ts b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts new file mode 100644 index 0000000000..b003ef7561 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts @@ -0,0 +1,108 @@ +import type { DataSourceItem } from '../types' +import { transformDataSourceToTool } from '../utils' + +const createLocalizedText = (text: string) => ({ + en_US: text, + zh_Hans: text, +}) + +const createDataSourceItem = (overrides: Partial = {}): DataSourceItem => ({ + plugin_id: 'plugin-1', + plugin_unique_identifier: 'plugin-1@provider', + provider: 'provider-a', + declaration: { + credentials_schema: [{ name: 'api_key' }], + provider_type: 'hosted', + identity: { + author: 'Dify', + description: createLocalizedText('Datasource provider'), + icon: 'provider-icon', + label: createLocalizedText('Provider A'), + name: 'provider-a', + tags: ['retrieval', 'storage'], + }, + datasources: [ + { + description: createLocalizedText('Search in documents'), + identity: { + author: 'Dify', + label: createLocalizedText('Document Search'), + name: 'document_search', + provider: 'provider-a', + }, + parameters: [{ name: 'query', type: 'string' }], + output_schema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + }, + }, + ], + }, + is_authorized: true, + ...overrides, +}) + +describe('transformDataSourceToTool', () => { + it('should map datasource provider fields to tool shape', () => { + const dataSourceItem = createDataSourceItem() + + const result = transformDataSourceToTool(dataSourceItem) + + expect(result).toMatchObject({ + id: 'plugin-1', + provider: 'provider-a', + name: 'provider-a', + author: 'Dify', + description: createLocalizedText('Datasource provider'), + icon: 'provider-icon', + label: createLocalizedText('Provider A'), + type: 'hosted', + allow_delete: true, + is_authorized: true, + is_team_authorization: true, + labels: ['retrieval', 'storage'], + plugin_id: 'plugin-1', + plugin_unique_identifier: 'plugin-1@provider', + credentialsSchema: [{ name: 'api_key' }], + meta: { version: '' }, + }) + expect(result.team_credentials).toEqual({}) + expect(result.tools).toEqual([ + { + name: 'document_search', + author: 'Dify', + label: createLocalizedText('Document Search'), + description: createLocalizedText('Search in documents'), + parameters: [{ name: 'query', type: 'string' }], + labels: [], + output_schema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + }, + }, + ]) + }) + + it('should fallback to empty arrays when tags and credentials schema are missing', () => { + const baseDataSourceItem = createDataSourceItem() + const dataSourceItem = createDataSourceItem({ + declaration: { + ...baseDataSourceItem.declaration, + credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'], + identity: { + ...baseDataSourceItem.declaration.identity, + tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'], + }, + }, + }) + + const result = transformDataSourceToTool(dataSourceItem) + + expect(result.labels).toEqual([]) + expect(result.credentialsSchema).toEqual([]) + }) +}) diff --git a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx new file mode 100644 index 0000000000..40e5bacd83 --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render } from '@testing-library/react' +import ViewTypeSelect, { ViewType } from '../view-type-select' + +const getViewOptions = (container: HTMLElement) => { + const options = container.firstElementChild?.children + if (!options || options.length !== 2) + throw new Error('Expected two view options') + return [options[0] as HTMLDivElement, options[1] as HTMLDivElement] +} + +describe('ViewTypeSelect', () => { + it('should highlight the active view type', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + + const [flatOption, treeOption] = getViewOptions(container) + + expect(flatOption).toHaveClass('bg-components-segmented-control-item-active-bg') + expect(treeOption).toHaveClass('cursor-pointer') + }) + + it('should call onChange when switching to a different view type', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + + const [, treeOption] = getViewOptions(container) + fireEvent.click(treeOption) + + expect(onChange).toHaveBeenCalledWith(ViewType.tree) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it('should ignore clicks on the current view type', () => { + const onChange = vi.fn() + const { container } = render( + , + ) + + const [, treeOption] = getViewOptions(container) + fireEvent.click(treeOption) + + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx index d122faecf6..05bded8bee 100644 --- a/web/app/components/workflow/block-selector/all-start-blocks.tsx +++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx @@ -6,7 +6,6 @@ import type { BlockEnum, OnSelectBlock } from '../types' import type { ListRef } from './market-place-plugin/list' import type { TriggerDefaultValue, TriggerWithProvider } from './types' import { RiArrowRightUpLine } from '@remixicon/react' -import Link from 'next/link' import { useCallback, useEffect, @@ -19,6 +18,7 @@ import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' import { useGlobalPublicStore } from '@/context/global-public-context' +import Link from '@/next/link' import { useFeaturedTriggersRecommendations } from '@/service/use-plugins' import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers' import { cn } from '@/utils/classnames' diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx index bde5390cd7..da74305e5f 100644 --- a/web/app/components/workflow/block-selector/all-tools.tsx +++ b/web/app/components/workflow/block-selector/all-tools.tsx @@ -12,7 +12,6 @@ import type { ToolDefaultValue, ToolValue } from './types' import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import type { OnSelectBlock } from '@/app/components/workflow/types' import { RiArrowRightUpLine } from '@remixicon/react' -import Link from 'next/link' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -21,6 +20,7 @@ import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general' import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list' import { useGlobalPublicStore } from '@/context/global-public-context' import { useGetLanguage } from '@/context/i18n' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { getMarketplaceUrl } from '@/utils/var' import { useMarketplacePlugins } from '../../plugins/marketplace/hooks' diff --git a/web/app/components/workflow/block-selector/featured-tools.tsx b/web/app/components/workflow/block-selector/featured-tools.tsx index 4e66f08222..965cf97cd0 100644 --- a/web/app/components/workflow/block-selector/featured-tools.tsx +++ b/web/app/components/workflow/block-selector/featured-tools.tsx @@ -4,7 +4,6 @@ import type { ToolDefaultValue, ToolValue } from './types' import type { Plugin } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { RiMoreLine } from '@remixicon/react' -import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' @@ -13,6 +12,7 @@ import Tooltip from '@/app/components/base/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' import { useGetLanguage } from '@/context/i18n' +import Link from '@/next/link' import { isServer } from '@/utils/client' import { formatNumber } from '@/utils/format' import { getMarketplaceUrl } from '@/utils/var' diff --git a/web/app/components/workflow/block-selector/featured-triggers.tsx b/web/app/components/workflow/block-selector/featured-triggers.tsx index 01cb5d100f..81715c2922 100644 --- a/web/app/components/workflow/block-selector/featured-triggers.tsx +++ b/web/app/components/workflow/block-selector/featured-triggers.tsx @@ -3,7 +3,6 @@ import type { TriggerDefaultValue, TriggerWithProvider } from './types' import type { Plugin } from '@/app/components/plugins/types' import type { Locale } from '@/i18n-config' import { RiMoreLine } from '@remixicon/react' -import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows' @@ -12,6 +11,7 @@ import Tooltip from '@/app/components/base/tooltip' import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace' import Action from '@/app/components/workflow/block-selector/market-place-plugin/action' import { useGetLanguage } from '@/context/i18n' +import Link from '@/next/link' import { isServer } from '@/utils/client' import { formatNumber } from '@/utils/format' import { getMarketplaceUrl } from '@/utils/var' diff --git a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx index 29f1e77e14..b5285758fd 100644 --- a/web/app/components/workflow/block-selector/market-place-plugin/list.tsx +++ b/web/app/components/workflow/block-selector/market-place-plugin/list.tsx @@ -3,9 +3,9 @@ import type { RefObject } from 'react' import type { Plugin, PluginCategoryEnum } from '@/app/components/plugins/types' import { RiArrowRightUpLine, RiSearchLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import Link from 'next/link' import { useEffect, useImperativeHandle, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' +import Link from '@/next/link' import { cn } from '@/utils/classnames' import { getMarketplaceUrl } from '@/utils/var' import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll' diff --git a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx index e934f27fd1..77acd5b300 100644 --- a/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx +++ b/web/app/components/workflow/block-selector/rag-tool-recommendations/index.tsx @@ -3,13 +3,13 @@ import type { Dispatch, SetStateAction } from 'react' import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select' import type { OnSelectBlock } from '@/app/components/workflow/types' import { RiMoreLine } from '@remixicon/react' -import Link from 'next/link' import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows' import Loading from '@/app/components/base/loading' import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils' +import Link from '@/next/link' import { useRAGRecommendedPlugins } from '@/service/use-tools' import { isServer } from '@/utils/client' import { getMarketplaceUrl } from '@/utils/var' diff --git a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts index e8f5fc0559..e5c1f208fb 100644 --- a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts +++ b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -const useCheckVerticalScrollbar = (ref: React.RefObject) => { +const useCheckVerticalScrollbar = (ref: React.RefObject) => { const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false) useEffect(() => { diff --git a/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx new file mode 100644 index 0000000000..ebe8321044 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx @@ -0,0 +1,59 @@ +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import ChatVariableButton from '../chat-variable-button' + +let mockTheme: 'light' | 'dark' = 'light' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: mockTheme, + }), +})) + +describe('ChatVariableButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + it('opens the chat variable panel and closes the other workflow panels', () => { + const { store } = renderWorkflowComponent(, { + initialStoreState: { + showEnvPanel: true, + showGlobalVariablePanel: true, + showDebugAndPreviewPanel: true, + }, + }) + + fireEvent.click(screen.getByRole('button')) + + expect(store.getState().showChatVariablePanel).toBe(true) + expect(store.getState().showEnvPanel).toBe(false) + expect(store.getState().showGlobalVariablePanel).toBe(false) + expect(store.getState().showDebugAndPreviewPanel).toBe(false) + }) + + it('applies the active dark theme styles when the chat variable panel is visible', () => { + mockTheme = 'dark' + renderWorkflowComponent(, { + initialStoreState: { + showChatVariablePanel: true, + }, + }) + + expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm') + }) + + it('stays disabled without mutating panel state', () => { + const { store } = renderWorkflowComponent(, { + initialStoreState: { + showChatVariablePanel: false, + }, + }) + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByRole('button')).toBeDisabled() + expect(store.getState().showChatVariablePanel).toBe(false) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/editing-title.spec.tsx b/web/app/components/workflow/header/__tests__/editing-title.spec.tsx new file mode 100644 index 0000000000..2dbb1b4b86 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/editing-title.spec.tsx @@ -0,0 +1,63 @@ +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import EditingTitle from '../editing-title' + +const mockFormatTime = vi.fn() +const mockFormatTimeFromNow = vi.fn() + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: mockFormatTime, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: mockFormatTimeFromNow, + }), +})) + +describe('EditingTitle', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFormatTime.mockReturnValue('08:00:00') + mockFormatTimeFromNow.mockReturnValue('2 hours ago') + }) + + it('should render autosave, published time, and syncing status when the draft has metadata', () => { + const { container } = renderWorkflowComponent(, { + initialStoreState: { + draftUpdatedAt: 1_710_000_000_000, + publishedAt: 1_710_003_600_000, + isSyncingWorkflowDraft: true, + maximizeCanvas: true, + }, + }) + + expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss') + expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000) + expect(container.firstChild).toHaveClass('ml-2') + expect(container).toHaveTextContent('workflow.common.autoSaved') + expect(container).toHaveTextContent('08:00:00') + expect(container).toHaveTextContent('workflow.common.published') + expect(container).toHaveTextContent('2 hours ago') + expect(container).toHaveTextContent('workflow.common.syncingData') + }) + + it('should render unpublished status without autosave metadata when the workflow has not been published', () => { + const { container } = renderWorkflowComponent(, { + initialStoreState: { + draftUpdatedAt: 0, + publishedAt: 0, + isSyncingWorkflowDraft: false, + maximizeCanvas: false, + }, + }) + + expect(mockFormatTime).not.toHaveBeenCalled() + expect(mockFormatTimeFromNow).not.toHaveBeenCalled() + expect(container.firstChild).not.toHaveClass('ml-2') + expect(container).toHaveTextContent('workflow.common.unpublished') + expect(container).not.toHaveTextContent('workflow.common.autoSaved') + expect(container).not.toHaveTextContent('workflow.common.syncingData') + }) +}) diff --git a/web/app/components/workflow/header/__tests__/env-button.spec.tsx b/web/app/components/workflow/header/__tests__/env-button.spec.tsx new file mode 100644 index 0000000000..268c54714e --- /dev/null +++ b/web/app/components/workflow/header/__tests__/env-button.spec.tsx @@ -0,0 +1,68 @@ +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import EnvButton from '../env-button' + +const mockCloseAllInputFieldPanels = vi.fn() +let mockTheme: 'light' | 'dark' = 'light' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: mockTheme, + }), +})) + +vi.mock('@/app/components/rag-pipeline/hooks', () => ({ + useInputFieldPanel: () => ({ + closeAllInputFieldPanels: mockCloseAllInputFieldPanels, + }), +})) + +describe('EnvButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + it('should open the environment panel and close the other panels when clicked', () => { + const { store } = renderWorkflowComponent(, { + initialStoreState: { + showChatVariablePanel: true, + showGlobalVariablePanel: true, + showDebugAndPreviewPanel: true, + }, + }) + + fireEvent.click(screen.getByRole('button')) + + expect(store.getState().showEnvPanel).toBe(true) + expect(store.getState().showChatVariablePanel).toBe(false) + expect(store.getState().showGlobalVariablePanel).toBe(false) + expect(store.getState().showDebugAndPreviewPanel).toBe(false) + expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) + }) + + it('should apply the active dark theme styles when the environment panel is visible', () => { + mockTheme = 'dark' + renderWorkflowComponent(, { + initialStoreState: { + showEnvPanel: true, + }, + }) + + expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm') + }) + + it('should keep the button disabled when the disabled prop is true', () => { + const { store } = renderWorkflowComponent(, { + initialStoreState: { + showEnvPanel: false, + }, + }) + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByRole('button')).toBeDisabled() + expect(store.getState().showEnvPanel).toBe(false) + expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx new file mode 100644 index 0000000000..fe17f940b8 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx @@ -0,0 +1,68 @@ +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import GlobalVariableButton from '../global-variable-button' + +const mockCloseAllInputFieldPanels = vi.fn() +let mockTheme: 'light' | 'dark' = 'light' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: mockTheme, + }), +})) + +vi.mock('@/app/components/rag-pipeline/hooks', () => ({ + useInputFieldPanel: () => ({ + closeAllInputFieldPanels: mockCloseAllInputFieldPanels, + }), +})) + +describe('GlobalVariableButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + it('should open the global variable panel and close the other panels when clicked', () => { + const { store } = renderWorkflowComponent(, { + initialStoreState: { + showEnvPanel: true, + showChatVariablePanel: true, + showDebugAndPreviewPanel: true, + }, + }) + + fireEvent.click(screen.getByRole('button')) + + expect(store.getState().showGlobalVariablePanel).toBe(true) + expect(store.getState().showEnvPanel).toBe(false) + expect(store.getState().showChatVariablePanel).toBe(false) + expect(store.getState().showDebugAndPreviewPanel).toBe(false) + expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) + }) + + it('should apply the active dark theme styles when the global variable panel is visible', () => { + mockTheme = 'dark' + renderWorkflowComponent(, { + initialStoreState: { + showGlobalVariablePanel: true, + }, + }) + + expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm') + }) + + it('should keep the button disabled when the disabled prop is true', () => { + const { store } = renderWorkflowComponent(, { + initialStoreState: { + showGlobalVariablePanel: false, + }, + }) + + fireEvent.click(screen.getByRole('button')) + + expect(screen.getByRole('button')).toBeDisabled() + expect(store.getState().showGlobalVariablePanel).toBe(false) + expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx new file mode 100644 index 0000000000..f5d138af42 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx @@ -0,0 +1,109 @@ +import type { VersionHistory } from '@/types/workflow' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import { WorkflowVersion } from '../../types' +import RestoringTitle from '../restoring-title' + +const mockFormatTime = vi.fn() +const mockFormatTimeFromNow = vi.fn() + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: mockFormatTime, + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: mockFormatTimeFromNow, + }), +})) + +const createVersion = (overrides: Partial = {}): VersionHistory => ({ + id: 'version-1', + graph: { + nodes: [], + edges: [], + }, + created_at: 1_700_000_000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + hash: 'hash-1', + updated_at: 1_700_000_100, + updated_by: { + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + }, + tool_published: false, + version: 'v1', + marked_name: 'Release 1', + marked_comment: '', + ...overrides, +}) + +describe('RestoringTitle', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFormatTime.mockReturnValue('09:30:00') + mockFormatTimeFromNow.mockReturnValue('3 hours ago') + }) + + it('should render draft metadata when the current version is a draft', () => { + const currentVersion = createVersion({ + version: WorkflowVersion.Draft, + }) + + const { container } = renderWorkflowComponent(, { + initialStoreState: { + currentVersion, + }, + }) + + expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000) + expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss') + expect(container).toHaveTextContent('workflow.versionHistory.currentDraft') + expect(container).toHaveTextContent('workflow.common.viewOnly') + expect(container).toHaveTextContent('workflow.common.unpublished') + expect(container).toHaveTextContent('3 hours ago 09:30:00') + expect(container).toHaveTextContent('Alice') + }) + + it('should render published metadata and fallback version name when the marked name is empty', () => { + const currentVersion = createVersion({ + marked_name: '', + }) + + const { container } = renderWorkflowComponent(, { + initialStoreState: { + currentVersion, + }, + }) + + expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000) + expect(container).toHaveTextContent('workflow.versionHistory.defaultName') + expect(container).toHaveTextContent('workflow.common.published') + expect(container).toHaveTextContent('Alice') + }) + + it('should render an empty creator name when the version creator name is missing', () => { + const currentVersion = createVersion({ + created_by: { + id: 'user-1', + name: '', + email: 'alice@example.com', + }, + }) + + const { container } = renderWorkflowComponent(, { + initialStoreState: { + currentVersion, + }, + }) + + expect(container).toHaveTextContent('workflow.common.published') + expect(container).not.toHaveTextContent('Alice') + }) +}) diff --git a/web/app/components/workflow/header/__tests__/running-title.spec.tsx b/web/app/components/workflow/header/__tests__/running-title.spec.tsx new file mode 100644 index 0000000000..7d904ed74a --- /dev/null +++ b/web/app/components/workflow/header/__tests__/running-title.spec.tsx @@ -0,0 +1,61 @@ +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import RunningTitle from '../running-title' + +let mockIsChatMode = false +const mockFormatWorkflowRunIdentifier = vi.fn() + +vi.mock('../../hooks', () => ({ + useIsChatMode: () => mockIsChatMode, +})) + +vi.mock('../../utils', () => ({ + formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt), +})) + +describe('RunningTitle', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsChatMode = false + mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)') + }) + + it('should render the test run title in workflow mode', () => { + const { container } = renderWorkflowComponent(, { + initialStoreState: { + historyWorkflowData: { + id: 'history-1', + status: 'succeeded', + finished_at: 1_700_000_000, + }, + }, + }) + + expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000) + expect(container).toHaveTextContent('Test Run (14:30:25)') + expect(container).toHaveTextContent('workflow.common.viewOnly') + }) + + it('should render the test chat title in chat mode', () => { + mockIsChatMode = true + + const { container } = renderWorkflowComponent(, { + initialStoreState: { + historyWorkflowData: { + id: 'history-2', + status: 'running', + finished_at: undefined, + }, + }, + }) + + expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined) + expect(container).toHaveTextContent('Test Chat (14:30:25)') + }) + + it('should handle missing workflow history data', () => { + const { container } = renderWorkflowComponent() + + expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined) + expect(container).toHaveTextContent('Test Run (14:30:25)') + }) +}) diff --git a/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx new file mode 100644 index 0000000000..7fbc70db23 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx @@ -0,0 +1,53 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { createNode } from '../../__tests__/fixtures' +import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state' +import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button' + +const mockScrollToWorkflowNode = vi.fn() + +vi.mock('reactflow', async () => + (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()) + +vi.mock('../../utils/node-navigation', () => ({ + scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId), +})) + +describe('ScrollToSelectedNodeButton', () => { + beforeEach(() => { + vi.clearAllMocks() + resetReactFlowMockState() + }) + + it('should render nothing when there is no selected node', () => { + rfState.nodes = [ + createNode({ + id: 'node-1', + data: { selected: false }, + }), + ] + + const { container } = render() + + expect(container.firstChild).toBeNull() + }) + + it('should render the action and scroll to the selected node when clicked', () => { + rfState.nodes = [ + createNode({ + id: 'node-1', + data: { selected: false }, + }), + createNode({ + id: 'node-2', + data: { selected: true }, + }), + ] + + render() + + fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode')) + + expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2') + expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx new file mode 100644 index 0000000000..767de6a6a8 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx @@ -0,0 +1,118 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import UndoRedo from '../undo-redo' + +type TemporalSnapshot = { + pastStates: unknown[] + futureStates: unknown[] +} + +const mockUnsubscribe = vi.fn() +const mockTemporalSubscribe = vi.fn() +const mockHandleUndo = vi.fn() +const mockHandleRedo = vi.fn() + +let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined +let mockNodesReadOnly = false + +vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + }), +})) + +vi.mock('@/app/components/workflow/workflow-history-store', () => ({ + useWorkflowHistoryStore: () => ({ + store: { + temporal: { + subscribe: mockTemporalSubscribe, + }, + }, + shortcutsEnabled: true, + setShortcutsEnabled: vi.fn(), + }), +})) + +vi.mock('@/app/components/base/divider', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/workflow/operator/tip-popup', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +describe('UndoRedo', () => { + beforeEach(() => { + vi.clearAllMocks() + mockNodesReadOnly = false + latestTemporalListener = undefined + mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => { + latestTemporalListener = listener + return mockUnsubscribe + }) + }) + + it('enables undo and redo when history exists and triggers the callbacks', () => { + render() + + act(() => { + latestTemporalListener?.({ + pastStates: [{}], + futureStates: [{}], + }) + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' })) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' })) + + expect(mockHandleUndo).toHaveBeenCalledTimes(1) + expect(mockHandleRedo).toHaveBeenCalledTimes(1) + }) + + it('keeps the buttons disabled before history is available', () => { + render() + const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' }) + const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' }) + + fireEvent.click(undoButton) + fireEvent.click(redoButton) + + expect(undoButton).toBeDisabled() + expect(redoButton).toBeDisabled() + expect(mockHandleUndo).not.toHaveBeenCalled() + expect(mockHandleRedo).not.toHaveBeenCalled() + }) + + it('does not trigger callbacks when the canvas is read only', () => { + mockNodesReadOnly = true + render() + const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' }) + const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' }) + + act(() => { + latestTemporalListener?.({ + pastStates: [{}], + futureStates: [{}], + }) + }) + + fireEvent.click(undoButton) + fireEvent.click(redoButton) + + expect(undoButton).toBeDisabled() + expect(redoButton).toBeDisabled() + expect(mockHandleUndo).not.toHaveBeenCalled() + expect(mockHandleRedo).not.toHaveBeenCalled() + }) + + it('unsubscribes from the temporal store on unmount', () => { + const { unmount } = render() + + unmount() + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx new file mode 100644 index 0000000000..bc066adba5 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import VersionHistoryButton from '../version-history-button' + +let mockTheme: 'light' | 'dark' = 'light' + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: mockTheme, + }), +})) + +vi.mock('../../utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getKeyboardKeyCodeBySystem: () => 'ctrl', + } +}) + +describe('VersionHistoryButton', () => { + beforeEach(() => { + vi.clearAllMocks() + mockTheme = 'light' + }) + + it('should call onClick when the button is clicked', () => { + const onClick = vi.fn() + render() + + fireEvent.click(screen.getByRole('button')) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should trigger onClick when the version history shortcut is pressed', () => { + const onClick = vi.fn() + render() + + const keyboardEvent = new KeyboardEvent('keydown', { + key: 'H', + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }) + Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 }) + Object.defineProperty(keyboardEvent, 'which', { value: 72 }) + window.dispatchEvent(keyboardEvent) + + expect(keyboardEvent.defaultPrevented).toBe(true) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('should render the tooltip popup content on hover', async () => { + render() + + fireEvent.mouseEnter(screen.getByRole('button')) + + expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument() + }) + + it('should apply dark theme styles when the theme is dark', () => { + mockTheme = 'dark' + render() + + expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm') + }) +}) diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx new file mode 100644 index 0000000000..4481c72cf7 --- /dev/null +++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx @@ -0,0 +1,276 @@ +import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow' +import { fireEvent, screen } from '@testing-library/react' +import * as React from 'react' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import { ControlMode, WorkflowRunningStatus } from '../../types' +import ViewHistory from '../view-history' + +const mockUseWorkflowRunHistory = vi.fn() +const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`) +const mockCloseAllInputFieldPanels = vi.fn() +const mockHandleNodesCancelSelected = vi.fn() +const mockHandleCancelDebugAndPreviewPanel = vi.fn() +const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`) + +let mockIsChatMode = false + +vi.mock('../../hooks', async () => { + const actual = await vi.importActual('../../hooks') + return { + ...actual, + useIsChatMode: () => mockIsChatMode, + useNodesInteractions: () => ({ + handleNodesCancelSelected: mockHandleNodesCancelSelected, + }), + useWorkflowInteractions: () => ({ + handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel, + }), + } +}) + +vi.mock('@/service/use-workflow', () => ({ + useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: mockFormatTimeFromNow, + }), +})) + +vi.mock('@/app/components/rag-pipeline/hooks', () => ({ + useInputFieldPanel: () => ({ + closeAllInputFieldPanels: mockCloseAllInputFieldPanels, + }), +})) + +vi.mock('@/app/components/base/loading', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/tooltip', () => ({ + default: ({ children }: { children?: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/app/components/base/portal-to-follow-elem', () => { + const PortalContext = React.createContext({ open: false }) + + return { + PortalToFollowElem: ({ + children, + open, + }: { + children?: React.ReactNode + open: boolean + }) => {children}, + PortalToFollowElemTrigger: ({ + children, + onClick, + }: { + children?: React.ReactNode + onClick?: () => void + }) =>
{children}
, + PortalToFollowElemContent: ({ + children, + }: { + children?: React.ReactNode + }) => { + const { open } = React.useContext(PortalContext) + return open ?
{children}
: null + }, + } +}) + +vi.mock('../../utils', async () => { + const actual = await vi.importActual('../../utils') + return { + ...actual, + formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status), + } +}) + +const createHistoryItem = (overrides: Partial = {}): WorkflowRunHistory => ({ + id: 'run-1', + version: 'v1', + graph: { + nodes: [], + edges: [], + }, + inputs: {}, + status: WorkflowRunningStatus.Succeeded, + outputs: {}, + elapsed_time: 1, + total_tokens: 2, + total_steps: 3, + created_at: 100, + finished_at: 120, + created_by_account: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + ...overrides, +}) + +describe('ViewHistory', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsChatMode = false + mockUseWorkflowRunHistory.mockReturnValue({ + data: { data: [] } satisfies WorkflowRunHistoryResponse, + isLoading: false, + }) + }) + + it('defers fetching until the history popup is opened and renders the empty state', () => { + renderWorkflowComponent(, { + hooksStoreProps: { + handleBackupDraft: vi.fn(), + }, + }) + + expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' })) + + expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true) + expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument() + expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument() + }) + + it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => { + const onClearLogAndMessageModal = vi.fn() + mockUseWorkflowRunHistory.mockReturnValue({ + data: { data: [] } satisfies WorkflowRunHistoryResponse, + isLoading: true, + }) + + renderWorkflowComponent( + , + { + hooksStoreProps: { + handleBackupDraft: vi.fn(), + }, + }, + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' })) + + expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('loading')).toBeInTheDocument() + }) + + 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, + created_at: 101, + finished_at: 0, + }) + const failedRun = createHistoryItem({ + id: 'run-failed', + status: WorkflowRunningStatus.Failed, + created_at: 102, + finished_at: 130, + }) + const succeededRun = createHistoryItem({ + id: 'run-succeeded', + status: WorkflowRunningStatus.Succeeded, + created_at: 103, + finished_at: 140, + }) + + mockUseWorkflowRunHistory.mockReturnValue({ + data: { + data: [pausedRun, failedRun, succeededRun], + } satisfies WorkflowRunHistoryResponse, + isLoading: false, + }) + + const { store } = renderWorkflowComponent(, { + initialStoreState: { + historyWorkflowData: failedRun, + showInputsPanel: true, + showEnvPanel: true, + controlMode: ControlMode.Pointer, + }, + hooksStoreProps: { + handleBackupDraft, + }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' })) + + expect(screen.getByText('Test Run (paused)')).toBeInTheDocument() + expect(screen.getByText('Test Run (failed)')).toBeInTheDocument() + expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Test Run (succeeded)')) + + expect(store.getState().historyWorkflowData).toEqual(succeededRun) + expect(store.getState().showInputsPanel).toBe(false) + expect(store.getState().showEnvPanel).toBe(false) + expect(store.getState().controlMode).toBe(ControlMode.Hand) + expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1) + expect(handleBackupDraft).toHaveBeenCalledTimes(1) + expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1) + expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1) + }) + + it('renders chat history labels without workflow status icons in chat mode', () => { + mockIsChatMode = true + const chatRun = createHistoryItem({ + id: 'chat-run', + status: WorkflowRunningStatus.Failed, + }) + + mockUseWorkflowRunHistory.mockReturnValue({ + data: { + data: [chatRun], + } satisfies WorkflowRunHistoryResponse, + isLoading: false, + }) + + renderWorkflowComponent(, { + hooksStoreProps: { + handleBackupDraft: vi.fn(), + }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' })) + + expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument() + }) + + it('closes the popup from the close button and clears log modals', () => { + const onClearLogAndMessageModal = vi.fn() + mockUseWorkflowRunHistory.mockReturnValue({ + data: { data: [] } satisfies WorkflowRunHistoryResponse, + isLoading: false, + }) + + renderWorkflowComponent( + , + { + hooksStoreProps: { + handleBackupDraft: vi.fn(), + }, + }, + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' })) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) + + expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1) + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/header/index.tsx b/web/app/components/workflow/header/index.tsx index bf7479b198..5e6b714213 100644 --- a/web/app/components/workflow/header/index.tsx +++ b/web/app/components/workflow/header/index.tsx @@ -1,8 +1,8 @@ import type { HeaderInNormalProps } from './header-in-normal' import type { HeaderInRestoringProps } from './header-in-restoring' import type { HeaderInHistoryProps } from './header-in-view-history' -import { usePathname } from 'next/navigation' import dynamic from '@/next/dynamic' +import { usePathname } from '@/next/navigation' import { useWorkflowMode, } from '../hooks' diff --git a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx index 5c9df54fb6..c7a1e97964 100644 --- a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx +++ b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx @@ -1,6 +1,5 @@ import type { FC } from 'react' import type { CommonNodeType } from '../types' -import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { cn } from '@/utils/classnames' @@ -11,21 +10,15 @@ const ScrollToSelectedNodeButton: FC = () => { const nodes = useNodes() const selectedNode = nodes.find(node => node.data.selected) - const handleScrollToSelectedNode = useCallback(() => { - if (!selectedNode) - return - scrollToWorkflowNode(selectedNode.id) - }, [selectedNode]) - if (!selectedNode) return null return (
scrollToWorkflowNode(selectedNode.id)} > {t('panel.scrollToSelectedNode', { ns: 'workflow' })}
diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx index a90720aeb1..c6b91972c9 100644 --- a/web/app/components/workflow/header/undo-redo.tsx +++ b/web/app/components/workflow/header/undo-redo.tsx @@ -1,8 +1,4 @@ import type { FC } from 'react' -import { - RiArrowGoBackLine, - RiArrowGoForwardFill, -} from '@remixicon/react' import { memo, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history' @@ -33,28 +29,34 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => { return (
-
!nodesReadOnly && !buttonsDisabled.undo && handleUndo()} + onClick={handleUndo} > - -
+ +
-
!nodesReadOnly && !buttonsDisabled.redo && handleRedo()} + onClick={handleRedo} > - -
+ +
diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx index 94963e29fc..162d46f8fe 100644 --- a/web/app/components/workflow/header/view-history.tsx +++ b/web/app/components/workflow/header/view-history.tsx @@ -73,15 +73,18 @@ const ViewHistory = ({ setOpen(v => !v)}> { withText && ( -
{t('common.showRunHistory', { ns: 'workflow' })} -
+ ) } { @@ -89,14 +92,16 @@ const ViewHistory = ({ -
{ onClearLogAndMessageModal?.() }} > -
+
) } @@ -110,7 +115,9 @@ const ViewHistory = ({ >
{t('common.runHistory', { ns: 'workflow' })}
-
{ onClearLogAndMessageModal?.() @@ -118,7 +125,7 @@ const ViewHistory = ({ }} > -
+
{ isLoading && ( diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx index 4635a5575c..f872fefe19 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx @@ -4,7 +4,6 @@ import type { Strategy } from './agent-strategy' import type { StrategyPluginDetail } from '@/app/components/plugins/types' import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list' import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react' -import Link from 'next/link' import { memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' @@ -17,6 +16,7 @@ import { PluginCategoryEnum } from '@/app/components/plugins/types' import { CollectionType } from '@/app/components/tools/types' import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list' import { useGlobalPublicStore } from '@/context/global-public-context' +import Link from '@/next/link' import { useStrategyProviders } from '@/service/use-strategy' import { cn } from '@/utils/classnames' import Tools from '../../../block-selector/tools' diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx index ba30053b77..70c480892b 100644 --- a/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx +++ b/web/app/components/workflow/nodes/_base/components/agent-strategy.tsx @@ -5,7 +5,6 @@ import type { ToolVarInputs } from '../../tool/types' import type { CredentialFormSchema, CredentialFormSchemaNumberInput, CredentialFormSchemaTextInput } from '@/app/components/header/account-setting/model-provider-page/declarations' import type { PluginMeta } from '@/app/components/plugins/types' import { noop } from 'es-toolkit/function' -import Link from 'next/link' import { memo } from 'react' import { useTranslation } from 'react-i18next' import { Agent } from '@/app/components/base/icons/src/vender/workflow' @@ -26,6 +25,7 @@ import MultipleToolSelector from '@/app/components/plugins/plugin-detail-panel/m import ToolSelector from '@/app/components/plugins/plugin-detail-panel/tool-selector' import { useDocLink } from '@/context/i18n' import { useRenderI18nObject } from '@/hooks/use-i18n' +import Link from '@/next/link' import { AppModeEnum } from '@/types/app' import { useWorkflowStore } from '../../../store' import { AgentStrategySelector } from './agent-strategy-selector' diff --git a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx index a76eba69ef..1843f77a52 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx @@ -1,54 +1,36 @@ import type { CommonNodeType } from '../../../types' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env' import { BlockEnum, NodeRunningStatus } from '../../../types' import NodeControl from './node-control' const { mockHandleNodeSelect, - mockSetInitShowLastRunTab, - mockSetPendingSingleRun, mockCanRunBySingle, } = vi.hoisted(() => ({ mockHandleNodeSelect: vi.fn(), - mockSetInitShowLastRunTab: vi.fn(), - mockSetPendingSingleRun: vi.fn(), mockCanRunBySingle: vi.fn(() => true), })) -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})) +let mockPluginInstallLocked = false -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( -
{children}
- ), -})) - -vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({ - Stop: ({ className }: { className?: string }) =>
, -})) - -vi.mock('../../../hooks', () => ({ - useNodesInteractions: () => ({ - handleNodeSelect: mockHandleNodeSelect, - }), -})) - -vi.mock('@/app/components/workflow/store', () => ({ - useWorkflowStore: () => ({ - getState: () => ({ - setInitShowLastRunTab: mockSetInitShowLastRunTab, - setPendingSingleRun: mockSetPendingSingleRun, +vi.mock('../../../hooks', async () => { + const actual = await vi.importActual('../../../hooks') + return { + ...actual, + useNodesInteractions: () => ({ + handleNodeSelect: mockHandleNodeSelect, }), - }), -})) + } +}) -vi.mock('../../../utils', () => ({ - canRunBySingle: mockCanRunBySingle, -})) +vi.mock('../../../utils', async () => { + const actual = await vi.importActual('../../../utils') + return { + ...actual, + canRunBySingle: mockCanRunBySingle, + } +}) vi.mock('./panel-operator', () => ({ default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => ( @@ -59,6 +41,16 @@ vi.mock('./panel-operator', () => ({ ), })) +function NodeControlHarness({ id, data }: { id: string, data: CommonNodeType, selected?: boolean }) { + return ( + + ) +} + const makeData = (overrides: Partial = {}): CommonNodeType => ({ type: BlockEnum.Code, title: 'Node', @@ -73,65 +65,71 @@ const makeData = (overrides: Partial = {}): CommonNodeType => ({ describe('NodeControl', () => { beforeEach(() => { vi.clearAllMocks() + mockPluginInstallLocked = false mockCanRunBySingle.mockReturnValue(true) }) - it('should trigger a single run and show the hover control when plugins are not locked', () => { - const { container } = render( - , - ) + // Run/stop behavior should be driven by the workflow store, not CSS classes. + describe('Single Run Actions', () => { + it('should trigger a single run through the workflow store', () => { + const { store } = renderWorkflowComponent( + , + ) - const wrapper = container.firstChild as HTMLElement - expect(wrapper.className).toContain('group-hover:flex') - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep') + fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' })) - fireEvent.click(screen.getByTestId('tooltip').parentElement!) + expect(store.getState().initShowLastRunTab).toBe(true) + expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'run' }) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1') + }) - expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true) - expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' }) - expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1') + it('should trigger stop when the node is already single-running', () => { + const { store } = renderWorkflowComponent( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' })) + + expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-2', action: 'stop' }) + expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-2') + }) }) - it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => { - const { container } = render( - , - ) + // Capability gating should hide the run control while leaving panel actions available. + describe('Availability', () => { + it('should keep the panel operator available when the plugin is install-locked', () => { + mockPluginInstallLocked = true - const wrapper = container.firstChild as HTMLElement - expect(wrapper.className).not.toContain('group-hover:flex') - expect(wrapper.className).toContain('!flex') - expect(screen.getByTestId('stop-icon')).toBeInTheDocument() + renderWorkflowComponent( + , + ) - fireEvent.click(screen.getByTestId('stop-icon').parentElement!) + expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() + }) - expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' }) + it('should hide the run control when single-node execution is not supported', () => { + mockCanRunBySingle.mockReturnValue(false) - fireEvent.click(screen.getByRole('button', { name: 'open panel' })) - expect(wrapper.className).toContain('!flex') - }) + renderWorkflowComponent( + , + ) - it('should hide the run control when single-node execution is not supported', () => { - mockCanRunBySingle.mockReturnValue(false) - - render( - , - ) - - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index 1ae697dfc4..ba2a4d3f73 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -1,8 +1,5 @@ import type { FC } from 'react' import type { Node } from '../../../types' -import { - RiPlayLargeLine, -} from '@remixicon/react' import { memo, useCallback, @@ -54,7 +51,9 @@ const NodeControl: FC = ({ > { canRunBySingle(data.type, isChildNode) && ( -
{ const action = isSingleRunning ? 'stop' : 'run' @@ -76,11 +75,11 @@ const NodeControl: FC = ({ popupContent={t('panel.runThisStep', { ns: 'workflow' })} asChild={false} > - + ) } -
+ ) } = ({ onRemove, }) => { const { t } = useTranslation() - const [toastHandler, setToastHandler] = useState() const list = outputKeyOrders.map((key) => { return { @@ -42,20 +40,17 @@ const OutputVarList: FC = ({ const { run: validateVarInput } = useDebounceFn((existingVariables: typeof list, newKey: string) => { const result = checkKeys([newKey], true) if (!result.isValid) { - setToastHandler(Toast.notify({ + toast.add({ type: 'error', - message: t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }), - })) + title: t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }), + }) return } if (existingVariables.some(key => key.variable?.trim() === newKey.trim())) { - setToastHandler(Toast.notify({ + toast.add({ type: 'error', - message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: newKey }), - })) - } - else { - toastHandler?.clear?.() + title: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: newKey }), + }) } }, { wait: 500 }) @@ -66,7 +61,6 @@ const OutputVarList: FC = ({ replaceSpaceWithUnderscoreInVarNameInput(e.target) const newKey = e.target.value - toastHandler?.clear?.() validateVarInput(list.toSpliced(index, 1), newKey) const newOutputs = produce(outputs, (draft) => { @@ -75,7 +69,7 @@ const OutputVarList: FC = ({ }) onChange(newOutputs, index, newKey) } - }, [list, onChange, outputs, outputKeyOrders, validateVarInput]) + }, [list, onChange, outputs, validateVarInput]) const handleVarTypeChange = useCallback((index: number) => { return (value: string) => { @@ -85,7 +79,7 @@ const OutputVarList: FC = ({ }) onChange(newOutputs) } - }, [list, onChange, outputs, outputKeyOrders]) + }, [list, onChange, outputs]) const handleVarRemove = useCallback((index: number) => { return () => { diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx index b5104377e1..0cb80f453f 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-list.tsx @@ -1,17 +1,16 @@ 'use client' import type { FC } from 'react' -import type { ToastHandle } from '@/app/components/base/toast' import type { ValueSelector, Var, Variable } from '@/app/components/workflow/types' import { RiDraggable } from '@remixicon/react' import { useDebounceFn } from 'ahooks' import { produce } from 'immer' import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { ReactSortable } from 'react-sortablejs' import { v4 as uuid4 } from 'uuid' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { cn } from '@/utils/classnames' import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var' @@ -42,7 +41,6 @@ const VarList: FC = ({ isSupportFileVar = true, }) => { const { t } = useTranslation() - const [toastHandle, setToastHandle] = useState() const listWithIds = useMemo(() => list.map((item) => { const id = uuid4() @@ -55,20 +53,17 @@ const VarList: FC = ({ const { run: validateVarInput } = useDebounceFn((list: Variable[], newKey: string) => { const result = checkKeys([newKey], true) if (!result.isValid) { - setToastHandle(Toast.notify({ + toast.add({ type: 'error', - message: t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }), - })) + title: t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }), + }) return } if (list.some(item => item.variable?.trim() === newKey.trim())) { - setToastHandle(Toast.notify({ + toast.add({ type: 'error', - message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: newKey }), - })) - } - else { - toastHandle?.clear?.() + title: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: newKey }), + }) } }, { wait: 500 }) @@ -78,7 +73,6 @@ const VarList: FC = ({ const newKey = e.target.value - toastHandle?.clear?.() validateVarInput(list.toSpliced(index, 1), newKey) onVarNameChange?.(list[index].variable, newKey) diff --git a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx index efeeb77c9e..ba5ebe50c2 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/__tests__/panel.spec.tsx @@ -1,90 +1,68 @@ import type { WebhookTriggerNodeType } from '../types' import type { NodePanelProps } from '@/app/components/workflow/types' import type { PanelProps } from '@/types/workflow' -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' import Panel from '../panel' const { mockHandleStatusCodeChange, mockGenerateWebhookUrl, + mockHandleMethodChange, + mockHandleContentTypeChange, + mockHandleHeadersChange, + mockHandleParamsChange, + mockHandleBodyChange, + mockHandleResponseBodyChange, } = vi.hoisted(() => ({ mockHandleStatusCodeChange: vi.fn(), mockGenerateWebhookUrl: vi.fn(), + mockHandleMethodChange: vi.fn(), + mockHandleContentTypeChange: vi.fn(), + mockHandleHeadersChange: vi.fn(), + mockHandleParamsChange: vi.fn(), + mockHandleBodyChange: vi.fn(), + mockHandleResponseBodyChange: vi.fn(), })) +const mockConfigState = { + readOnly: false, + inputs: { + method: 'POST', + webhook_url: 'https://example.com/webhook', + webhook_debug_url: '', + content_type: 'application/json', + headers: [], + params: [], + body: [], + status_code: 200, + response_body: 'ok', + variables: [], + }, +} + vi.mock('../use-config', () => ({ DEFAULT_STATUS_CODE: 200, MAX_STATUS_CODE: 399, normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399), useConfig: () => ({ - readOnly: false, - inputs: { - method: 'POST', - webhook_url: 'https://example.com/webhook', - webhook_debug_url: '', - content_type: 'application/json', - headers: [], - params: [], - body: [], - status_code: 200, - response_body: '', - }, - handleMethodChange: vi.fn(), - handleContentTypeChange: vi.fn(), - handleHeadersChange: vi.fn(), - handleParamsChange: vi.fn(), - handleBodyChange: vi.fn(), + readOnly: mockConfigState.readOnly, + inputs: mockConfigState.inputs, + handleMethodChange: mockHandleMethodChange, + handleContentTypeChange: mockHandleContentTypeChange, + handleHeadersChange: mockHandleHeadersChange, + handleParamsChange: mockHandleParamsChange, + handleBodyChange: mockHandleBodyChange, handleStatusCodeChange: mockHandleStatusCodeChange, - handleResponseBodyChange: vi.fn(), + handleResponseBodyChange: mockHandleResponseBodyChange, generateWebhookUrl: mockGenerateWebhookUrl, }), })) -vi.mock('@/app/components/base/input-with-copy', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/base/select', () => ({ - SimpleSelect: () =>
, -})) - -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: React.ReactNode }) => <>{children}, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => ( -
-
{title}
- {children} -
- ), -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({ - default: () =>
, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ - default: () =>
, -})) - -vi.mock('../components/header-table', () => ({ - default: () =>
, -})) - -vi.mock('../components/parameter-table', () => ({ - default: () =>
, -})) - -vi.mock('../components/paragraph-input', () => ({ - default: () =>
, -})) - -vi.mock('../utils/render-output-vars', () => ({ - OutputVariablesContent: () =>
, -})) +const getStatusCodeInput = () => { + return screen.getAllByDisplayValue('200') + .find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement +} describe('WebhookTriggerPanel', () => { const panelProps: NodePanelProps = { @@ -100,7 +78,7 @@ describe('WebhookTriggerPanel', () => { body: [], async_mode: false, status_code: 200, - response_body: '', + response_body: 'ok', variables: [], }, panelProps: {} as PanelProps, @@ -108,26 +86,65 @@ describe('WebhookTriggerPanel', () => { beforeEach(() => { vi.clearAllMocks() + mockConfigState.readOnly = false + mockConfigState.inputs = { + method: 'POST', + webhook_url: 'https://example.com/webhook', + webhook_debug_url: '', + content_type: 'application/json', + headers: [], + params: [], + body: [], + status_code: 200, + response_body: 'ok', + variables: [], + } }) - it('should update the status code when users enter a parseable value', () => { - render() + describe('Rendering', () => { + it('should render the real panel fields without generating a new webhook url when one already exists', () => { + render() - fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } }) + expect(screen.getByDisplayValue('https://example.com/webhook')).toBeInTheDocument() + expect(screen.getByText('application/json')).toBeInTheDocument() + expect(screen.getByDisplayValue('ok')).toBeInTheDocument() + expect(mockGenerateWebhookUrl).not.toHaveBeenCalled() + }) - expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201) + it('should request a webhook url when the node is writable and missing one', async () => { + mockConfigState.inputs = { + ...mockConfigState.inputs, + webhook_url: '', + } + + render() + + await waitFor(() => { + expect(mockGenerateWebhookUrl).toHaveBeenCalledTimes(1) + }) + }) }) - it('should ignore clear changes until the value is committed', () => { - render() + describe('Status Code Input', () => { + it('should update the status code when users enter a parseable value', () => { + render() - const input = screen.getByRole('textbox') - fireEvent.change(input, { target: { value: '' } }) + fireEvent.change(getStatusCodeInput(), { target: { value: '201' } }) - expect(mockHandleStatusCodeChange).not.toHaveBeenCalled() + expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201) + }) - fireEvent.blur(input) + it('should ignore clear changes until the value is committed', () => { + render() - expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200) + const input = getStatusCodeInput() + fireEvent.change(input, { target: { value: '' } }) + + expect(mockHandleStatusCodeChange).not.toHaveBeenCalled() + + fireEvent.blur(input) + + expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200) + }) }) }) diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx new file mode 100644 index 0000000000..ab7ec2ef0e --- /dev/null +++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx @@ -0,0 +1,225 @@ +import type { ReactNode } from 'react' +import { act, render, screen, waitFor } from '@testing-library/react' +import ReactFlow, { ReactFlowProvider } from 'reactflow' +import { FlowType } from '@/types/common' +import { BlockEnum } from '../../types' +import AddBlock from '../add-block' + +type BlockSelectorMockProps = { + open: boolean + onOpenChange: (open: boolean) => void + disabled: boolean + onSelect: (type: BlockEnum, pluginDefaultValue?: Record) => void + placement: string + offset: { + mainAxis: number + crossAxis: number + } + trigger: (open: boolean) => ReactNode + popupClassName: string + availableBlocksTypes: BlockEnum[] + showStartTab: boolean +} + +const { + mockHandlePaneContextmenuCancel, + mockWorkflowStoreSetState, + mockGenerateNewNode, + mockGetNodeCustomTypeByNodeDataType, +} = vi.hoisted(() => ({ + mockHandlePaneContextmenuCancel: vi.fn(), + mockWorkflowStoreSetState: vi.fn(), + mockGenerateNewNode: vi.fn(({ type, data }: { type: string, data: Record }) => ({ + newNode: { + id: 'generated-node', + type, + data, + }, + })), + mockGetNodeCustomTypeByNodeDataType: vi.fn((type: string) => `${type}-custom`), +})) + +let latestBlockSelectorProps: BlockSelectorMockProps | null = null +let mockNodesReadOnly = false +let mockIsChatMode = false +let mockFlowType: FlowType = FlowType.appFlow + +const mockAvailableNextBlocks = [BlockEnum.Answer, BlockEnum.Code] +const mockNodesMetaDataMap = { + [BlockEnum.Answer]: { + defaultValue: { + title: 'Answer', + desc: '', + type: BlockEnum.Answer, + }, + }, +} + +vi.mock('@/app/components/workflow/block-selector', () => ({ + default: (props: BlockSelectorMockProps) => { + latestBlockSelectorProps = props + return ( +
+ {props.trigger(props.open)} +
+ ) + }, +})) + +vi.mock('../../hooks', () => ({ + useAvailableBlocks: () => ({ + availableNextBlocks: mockAvailableNextBlocks, + }), + useIsChatMode: () => mockIsChatMode, + useNodesMetaData: () => ({ + nodesMap: mockNodesMetaDataMap, + }), + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + }), + usePanelInteractions: () => ({ + handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel, + }), +})) + +vi.mock('../../hooks-store', () => ({ + useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) => + selector({ configsMap: { flowType: mockFlowType } }), +})) + +vi.mock('../../store', () => ({ + useWorkflowStore: () => ({ + setState: mockWorkflowStoreSetState, + }), +})) + +vi.mock('../../utils', () => ({ + generateNewNode: mockGenerateNewNode, + getNodeCustomTypeByNodeDataType: mockGetNodeCustomTypeByNodeDataType, +})) + +vi.mock('../tip-popup', () => ({ + default: ({ children }: { children?: ReactNode }) => <>{children}, +})) + +const renderWithReactFlow = (nodes: Array<{ id: string, position: { x: number, y: number }, data: { type: BlockEnum } }>) => { + return render( +
+ + + + +
, + ) +} + +describe('AddBlock', () => { + beforeEach(() => { + vi.clearAllMocks() + latestBlockSelectorProps = null + mockNodesReadOnly = false + mockIsChatMode = false + mockFlowType = FlowType.appFlow + }) + + // Rendering and selector configuration. + describe('Rendering', () => { + it('should pass the selector props for a writable app workflow', async () => { + renderWithReactFlow([]) + + await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) + + expect(screen.getByTestId('block-selector')).toBeInTheDocument() + expect(latestBlockSelectorProps).toMatchObject({ + disabled: false, + availableBlocksTypes: mockAvailableNextBlocks, + showStartTab: true, + placement: 'right-start', + popupClassName: '!min-w-[256px]', + }) + expect(latestBlockSelectorProps?.offset).toEqual({ + mainAxis: 4, + crossAxis: -8, + }) + }) + + it('should hide the start tab for chat mode and rag pipeline flows', async () => { + mockIsChatMode = true + const { rerender } = renderWithReactFlow([]) + + await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) + + expect(latestBlockSelectorProps?.showStartTab).toBe(false) + + mockIsChatMode = false + mockFlowType = FlowType.ragPipeline + rerender( +
+ + + + +
, + ) + + expect(latestBlockSelectorProps?.showStartTab).toBe(false) + }) + }) + + // User interactions that bridge selector state and workflow state. + describe('User Interactions', () => { + it('should cancel the pane context menu when the selector closes', async () => { + renderWithReactFlow([]) + + await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) + + act(() => { + latestBlockSelectorProps?.onOpenChange(false) + }) + + expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(1) + }) + + it('should create a candidate node with an incremented title when a block is selected', async () => { + renderWithReactFlow([ + { id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } }, + { id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } }, + ]) + + await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) + + act(() => { + latestBlockSelectorProps?.onSelect(BlockEnum.Answer, { pluginId: 'plugin-1' }) + }) + + expect(mockGetNodeCustomTypeByNodeDataType).toHaveBeenCalledWith(BlockEnum.Answer) + expect(mockGenerateNewNode).toHaveBeenCalledWith({ + type: 'answer-custom', + data: { + title: 'Answer 3', + desc: '', + type: BlockEnum.Answer, + pluginId: 'plugin-1', + _isCandidate: true, + }, + position: { + x: 0, + y: 0, + }, + }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ + candidateNode: { + id: 'generated-node', + type: 'answer-custom', + data: { + title: 'Answer 3', + desc: '', + type: BlockEnum.Answer, + pluginId: 'plugin-1', + _isCandidate: true, + }, + }, + }) + }) + }) +}) diff --git a/web/app/components/workflow/operator/__tests__/control.spec.tsx b/web/app/components/workflow/operator/__tests__/control.spec.tsx new file mode 100644 index 0000000000..053d61d1ce --- /dev/null +++ b/web/app/components/workflow/operator/__tests__/control.spec.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { ControlMode } from '../../types' +import Control from '../control' + +type WorkflowStoreState = { + controlMode: ControlMode + maximizeCanvas: boolean +} + +const { + mockHandleAddNote, + mockHandleLayout, + mockHandleModeHand, + mockHandleModePointer, + mockHandleToggleMaximizeCanvas, +} = vi.hoisted(() => ({ + mockHandleAddNote: vi.fn(), + mockHandleLayout: vi.fn(), + mockHandleModeHand: vi.fn(), + mockHandleModePointer: vi.fn(), + mockHandleToggleMaximizeCanvas: vi.fn(), +})) + +let mockNodesReadOnly = false +let mockStoreState: WorkflowStoreState + +vi.mock('../../hooks', () => ({ + useNodesReadOnly: () => ({ + nodesReadOnly: mockNodesReadOnly, + getNodesReadOnly: () => mockNodesReadOnly, + }), + useWorkflowCanvasMaximize: () => ({ + handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas, + }), + useWorkflowMoveMode: () => ({ + handleModePointer: mockHandleModePointer, + handleModeHand: mockHandleModeHand, + }), + useWorkflowOrganize: () => ({ + handleLayout: mockHandleLayout, + }), +})) + +vi.mock('../hooks', () => ({ + useOperator: () => ({ + handleAddNote: mockHandleAddNote, + }), +})) + +vi.mock('../../store', () => ({ + useStore: (selector: (state: WorkflowStoreState) => unknown) => selector(mockStoreState), +})) + +vi.mock('../add-block', () => ({ + default: () =>
, +})) + +vi.mock('../more-actions', () => ({ + default: () =>
, +})) + +vi.mock('../tip-popup', () => ({ + default: ({ + children, + title, + }: { + children?: ReactNode + title?: string + }) =>
{children}
, +})) + +describe('Control', () => { + beforeEach(() => { + vi.clearAllMocks() + mockNodesReadOnly = false + mockStoreState = { + controlMode: ControlMode.Pointer, + maximizeCanvas: false, + } + }) + + // Rendering and visual states for control buttons. + describe('Rendering', () => { + it('should render the child action groups and highlight the active pointer mode', () => { + render() + + expect(screen.getByTestId('add-block')).toBeInTheDocument() + expect(screen.getByTestId('more-actions')).toBeInTheDocument() + expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass('bg-state-accent-active') + expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass('bg-state-accent-active') + expect(screen.getByTestId('workflow.panel.maximize')).toBeInTheDocument() + }) + + it('should switch the maximize tooltip and active style when the canvas is maximized', () => { + mockStoreState = { + controlMode: ControlMode.Hand, + maximizeCanvas: true, + } + + render() + + expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass('bg-state-accent-active') + expect(screen.getByTestId('workflow.panel.minimize').firstElementChild).toHaveClass('bg-state-accent-active') + }) + }) + + // User interactions exposed by the control bar. + describe('User Interactions', () => { + it('should trigger the note, mode, organize, and maximize handlers', () => { + render() + + fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement) + fireEvent.click(screen.getByTestId('workflow.common.pointerMode').firstElementChild as HTMLElement) + fireEvent.click(screen.getByTestId('workflow.common.handMode').firstElementChild as HTMLElement) + fireEvent.click(screen.getByTestId('workflow.panel.organizeBlocks').firstElementChild as HTMLElement) + fireEvent.click(screen.getByTestId('workflow.panel.maximize').firstElementChild as HTMLElement) + + expect(mockHandleAddNote).toHaveBeenCalledTimes(1) + expect(mockHandleModePointer).toHaveBeenCalledTimes(1) + expect(mockHandleModeHand).toHaveBeenCalledTimes(1) + expect(mockHandleLayout).toHaveBeenCalledTimes(1) + expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1) + }) + + it('should block note creation when the workflow is read only', () => { + mockNodesReadOnly = true + + render() + + fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement) + + expect(mockHandleAddNote).not.toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx new file mode 100644 index 0000000000..ddefe60b7e --- /dev/null +++ b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx @@ -0,0 +1,323 @@ +import type { Shape as HooksStoreShape } from '../../hooks-store/store' +import type { RunFile } from '../../types' +import type { FileUpload } from '@/app/components/base/features/types' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ReactFlow, { ReactFlowProvider } from 'reactflow' +import { TransferMethod } from '@/types/app' +import { FlowType } from '@/types/common' +import { createStartNode } from '../../__tests__/fixtures' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import { InputVarType, WorkflowRunningStatus } from '../../types' +import InputsPanel from '../inputs-panel' + +const mockCheckInputsForm = vi.fn() +const mockNotify = vi.fn() + +vi.mock('next/navigation', () => ({ + useParams: () => ({}), +})) + +vi.mock('@/app/components/base/toast/context', () => ({ + useToastContext: () => ({ + notify: mockNotify, + close: vi.fn(), + }), +})) + +vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({ + useCheckInputsForms: () => ({ + checkInputsForm: mockCheckInputsForm, + }), +})) + +const fileSettingsWithImage = { + enabled: true, + image: { + enabled: true, + }, + allowed_file_upload_methods: [TransferMethod.remote_url], + number_limits: 3, + image_file_size_limit: 10, +} satisfies FileUpload & { image_file_size_limit: number } + +const uploadedRunFile = { + transfer_method: TransferMethod.remote_url, + upload_file_id: 'file-2', +} as unknown as RunFile + +const uploadingRunFile = { + transfer_method: TransferMethod.local_file, +} as unknown as RunFile + +const createHooksStoreProps = ( + overrides: Partial = {}, +): Partial => ({ + handleRun: vi.fn(), + configsMap: { + flowId: 'flow-1', + flowType: FlowType.appFlow, + fileSettings: fileSettingsWithImage, + }, + ...overrides, +}) + +const renderInputsPanel = ( + startNode: ReturnType, + options?: Parameters[1], +) => { + return renderWorkflowComponent( +
+ + + + +
, + options, + ) +} + +describe('InputsPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckInputsForm.mockReturnValue(true) + }) + + describe('Rendering', () => { + it('should render current inputs, defaults, and the image uploader from the start node', () => { + renderInputsPanel( + createStartNode({ + data: { + variables: [ + { + type: InputVarType.textInput, + variable: 'question', + label: 'Question', + required: true, + default: 'default question', + }, + { + type: InputVarType.number, + variable: 'count', + label: 'Count', + required: false, + default: '2', + }, + ], + }, + }), + { + initialStoreState: { + inputs: { + question: 'overridden question', + }, + }, + hooksStoreProps: createHooksStoreProps(), + }, + ) + + expect(screen.getByDisplayValue('overridden question')).toHaveFocus() + expect(screen.getByRole('spinbutton')).toHaveValue(2) + expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should update workflow inputs and image files when users edit the form', async () => { + const user = userEvent.setup() + const { store } = renderInputsPanel( + createStartNode({ + data: { + variables: [ + { + type: InputVarType.textInput, + variable: 'question', + label: 'Question', + required: true, + }, + ], + }, + }), + { + hooksStoreProps: createHooksStoreProps(), + }, + ) + + await user.type(screen.getByPlaceholderText('Question'), 'changed question') + expect(store.getState().inputs).toEqual({ question: 'changed question' }) + + await user.click(screen.getByText('common.imageUploader.pasteImageLink')) + await user.type( + await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'), + 'https://example.com/image.png', + ) + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + + await waitFor(() => { + expect(store.getState().files).toEqual([{ + type: 'image', + transfer_method: TransferMethod.remote_url, + url: 'https://example.com/image.png', + upload_file_id: '', + }]) + }) + }) + + it('should not start a run when input validation fails', async () => { + const user = userEvent.setup() + mockCheckInputsForm.mockReturnValue(false) + const onRun = vi.fn() + const handleRun = vi.fn() + + renderWorkflowComponent( +
+ + + + +
, + { + hooksStoreProps: createHooksStoreProps({ handleRun }), + }, + ) + + await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })) + + expect(mockCheckInputsForm).toHaveBeenCalledWith( + { question: 'default question' }, + expect.arrayContaining([ + expect.objectContaining({ variable: 'question' }), + expect.objectContaining({ variable: '__image' }), + ]), + ) + expect(onRun).not.toHaveBeenCalled() + expect(handleRun).not.toHaveBeenCalled() + }) + + it('should start a run with processed inputs when validation succeeds', async () => { + const user = userEvent.setup() + const onRun = vi.fn() + const handleRun = vi.fn() + + renderWorkflowComponent( +
+ + + + +
, + { + initialStoreState: { + inputs: { + question: 'run this', + confirmed: 'truthy', + }, + files: [uploadedRunFile], + }, + hooksStoreProps: createHooksStoreProps({ + handleRun, + configsMap: { + flowId: 'flow-1', + flowType: FlowType.appFlow, + fileSettings: { + enabled: false, + }, + }, + }), + }, + ) + + await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })) + + expect(onRun).toHaveBeenCalledTimes(1) + expect(handleRun).toHaveBeenCalledWith({ + inputs: { + question: 'run this', + confirmed: true, + }, + files: [uploadedRunFile], + }) + }) + }) + + describe('Disabled States', () => { + it('should disable the run button while a local file is still uploading', () => { + renderInputsPanel(createStartNode(), { + initialStoreState: { + files: [uploadingRunFile], + }, + hooksStoreProps: createHooksStoreProps({ + configsMap: { + flowId: 'flow-1', + flowType: FlowType.appFlow, + fileSettings: { + enabled: false, + }, + }, + }), + }) + + expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled() + }) + + it('should disable the run button while the workflow is already running', () => { + renderInputsPanel(createStartNode(), { + initialStoreState: { + workflowRunningData: { + result: { + status: WorkflowRunningStatus.Running, + inputs_truncated: false, + process_data_truncated: false, + outputs_truncated: false, + }, + tracing: [], + }, + }, + hooksStoreProps: createHooksStoreProps(), + }) + + expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled() + }) + }) +}) diff --git a/web/app/components/workflow/panel/__tests__/record.spec.tsx b/web/app/components/workflow/panel/__tests__/record.spec.tsx new file mode 100644 index 0000000000..1d07098427 --- /dev/null +++ b/web/app/components/workflow/panel/__tests__/record.spec.tsx @@ -0,0 +1,163 @@ +import type { WorkflowRunDetailResponse } from '@/models/log' +import { act, screen } from '@testing-library/react' +import { createEdge, createNode } from '../../__tests__/fixtures' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import Record from '../record' + +const mockHandleUpdateWorkflowCanvas = vi.fn() +const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)') + +let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined + +vi.mock('@/app/components/workflow/hooks', () => ({ + useWorkflowUpdate: () => ({ + handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas, + }), +})) + +vi.mock('@/app/components/workflow/run', () => ({ + default: ({ + runDetailUrl, + tracingListUrl, + getResultCallback, + }: { + runDetailUrl: string + tracingListUrl: string + getResultCallback: (res: WorkflowRunDetailResponse) => void + }) => { + latestGetResultCallback = getResultCallback + return ( +
+ ) + }, +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt), +})) + +const createRunDetail = (overrides: Partial = {}): WorkflowRunDetailResponse => ({ + id: 'run-1', + version: '1', + graph: { + nodes: [], + edges: [], + }, + inputs: '{}', + inputs_truncated: false, + status: 'succeeded', + outputs: '{}', + outputs_truncated: false, + total_steps: 1, + created_by_role: 'account', + created_at: 1, + finished_at: 2, + ...overrides, +}) + +describe('Record', () => { + beforeEach(() => { + vi.clearAllMocks() + latestGetResultCallback = undefined + }) + + it('renders the run title and passes run and trace URLs to the run panel', () => { + const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({ + runUrl: `/runs/${runId}`, + traceUrl: `/traces/${runId}`, + })) + + renderWorkflowComponent(, { + initialStoreState: { + historyWorkflowData: { + id: 'run-1', + status: 'succeeded', + finished_at: 1700000000000, + }, + }, + hooksStoreProps: { + getWorkflowRunAndTraceUrl, + }, + }) + + expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument() + expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1') + expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1') + expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2) + expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1') + expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1') + expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000) + }) + + it('updates the workflow canvas with a fallback viewport when the response omits one', () => { + const nodes = [createNode({ id: 'node-1' })] + const edges = [createEdge({ id: 'edge-1' })] + + renderWorkflowComponent(, { + initialStoreState: { + historyWorkflowData: { + id: 'run-1', + status: 'succeeded', + }, + }, + hooksStoreProps: { + getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }), + }, + }) + + expect(latestGetResultCallback).toBeDefined() + + act(() => { + latestGetResultCallback?.(createRunDetail({ + graph: { + nodes, + edges, + }, + })) + }) + + expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ + nodes, + edges, + viewport: { x: 0, y: 0, zoom: 1 }, + }) + }) + + it('uses the response viewport when one is available', () => { + const nodes = [createNode({ id: 'node-1' })] + const edges = [createEdge({ id: 'edge-1' })] + const viewport = { x: 12, y: 24, zoom: 0.75 } + + renderWorkflowComponent(, { + initialStoreState: { + historyWorkflowData: { + id: 'run-1', + status: 'succeeded', + }, + }, + hooksStoreProps: { + getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }), + }, + }) + + act(() => { + latestGetResultCallback?.(createRunDetail({ + graph: { + nodes, + edges, + viewport, + }, + })) + }) + + expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({ + nodes, + edges, + viewport, + }) + }) +}) diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 0ad3ef0549..9439efc918 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -7,7 +7,7 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal' import Divider from '@/app/components/base/divider' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { useSelector as useAppContextSelector } from '@/context/app-context' import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks' @@ -118,9 +118,9 @@ export const VersionHistoryPanel = ({ break case VersionHistoryContextMenuOptions.copyId: copy(item.id) - Toast.notify({ + toast.add({ type: 'success', - message: t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }), + title: t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }), }) break case VersionHistoryContextMenuOptions.exportDSL: @@ -152,17 +152,17 @@ export const VersionHistoryPanel = ({ workflowStore.setState({ backupDraft: undefined }) handleSyncWorkflowDraft(true, false, { onSuccess: () => { - Toast.notify({ + toast.add({ type: 'success', - message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), + title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), }) deleteAllInspectVars() invalidAllLastRun() }, onError: () => { - Toast.notify({ + toast.add({ type: 'error', - message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), + title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), }) }, onSettled: () => { @@ -177,18 +177,18 @@ export const VersionHistoryPanel = ({ await deleteWorkflow(deleteVersionUrl?.(id) || '', { onSuccess: () => { setDeleteConfirmOpen(false) - Toast.notify({ + toast.add({ type: 'success', - message: t('versionHistory.action.deleteSuccess', { ns: 'workflow' }), + title: t('versionHistory.action.deleteSuccess', { ns: 'workflow' }), }) resetWorkflowVersionHistory() deleteAllInspectVars() invalidAllLastRun() }, onError: () => { - Toast.notify({ + toast.add({ type: 'error', - message: t('versionHistory.action.deleteFailure', { ns: 'workflow' }), + title: t('versionHistory.action.deleteFailure', { ns: 'workflow' }), }) }, onSettled: () => { @@ -207,16 +207,16 @@ export const VersionHistoryPanel = ({ }, { onSuccess: () => { setEditModalOpen(false) - Toast.notify({ + toast.add({ type: 'success', - message: t('versionHistory.action.updateSuccess', { ns: 'workflow' }), + title: t('versionHistory.action.updateSuccess', { ns: 'workflow' }), }) resetWorkflowVersionHistory() }, onError: () => { - Toast.notify({ + toast.add({ type: 'error', - message: t('versionHistory.action.updateFailure', { ns: 'workflow' }), + title: t('versionHistory.action.updateFailure', { ns: 'workflow' }), }) }, onSettled: () => { diff --git a/web/app/components/workflow/run/__tests__/meta.spec.tsx b/web/app/components/workflow/run/__tests__/meta.spec.tsx new file mode 100644 index 0000000000..2a1a4f4b1a --- /dev/null +++ b/web/app/components/workflow/run/__tests__/meta.spec.tsx @@ -0,0 +1,68 @@ +import { render, screen } from '@testing-library/react' +import Meta from '../meta' + +const mockFormatTime = vi.fn((value: number) => `formatted:${value}`) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: mockFormatTime, + }), +})) + +describe('Meta', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders loading placeholders while the run is in progress', () => { + const { container } = render() + + expect(container.querySelectorAll('.bg-text-quaternary')).toHaveLength(6) + expect(screen.queryByText('SUCCESS')).not.toBeInTheDocument() + expect(screen.queryByText('runLog.meta.steps')).toBeInTheDocument() + }) + + it.each([ + ['succeeded', 'SUCCESS'], + ['partial-succeeded', 'PARTIAL SUCCESS'], + ['exception', 'EXCEPTION'], + ['failed', 'FAIL'], + ['stopped', 'STOP'], + ['paused', 'PENDING'], + ] as const)('renders the %s status label', (status, label) => { + render() + + expect(screen.getByText(label)).toBeInTheDocument() + }) + + it('renders explicit metadata values and hides steps when requested', () => { + render( + , + ) + + expect(screen.getByText('Alice')).toBeInTheDocument() + expect(screen.getByText('formatted:1700000000000')).toBeInTheDocument() + expect(screen.getByText('1.235s')).toBeInTheDocument() + expect(screen.getByText('42 Tokens')).toBeInTheDocument() + expect(screen.queryByText('Run Steps')).not.toBeInTheDocument() + expect(mockFormatTime).toHaveBeenCalledWith(1700000000000, expect.any(String)) + }) + + it('falls back to default values when metadata is missing', () => { + render() + + expect(screen.getByText('N/A')).toBeInTheDocument() + expect(screen.getAllByText('-')).toHaveLength(2) + expect(screen.getByText('0 Tokens')).toBeInTheDocument() + expect(screen.getByText('runLog.meta.steps').parentElement).toHaveTextContent('1') + expect(mockFormatTime).not.toHaveBeenCalled() + }) +}) diff --git a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx new file mode 100644 index 0000000000..34b13011ed --- /dev/null +++ b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx @@ -0,0 +1,137 @@ +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import type { FileResponse } from '@/types/workflow' +import { render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import OutputPanel from '../output-panel' + +type FileOutput = FileResponse & { dify_model_identity: '__dify__file__' } + +vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileList: ({ files }: { files: FileEntity[] }) => ( +
{files.map(file => file.name).join(', ')}
+ ), +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +vi.mock('@/app/components/workflow/run/status-container', () => ({ + default: ({ status, children }: { status: string, children?: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({ + default: ({ + language, + value, + height, + }: { + language: string + value: string + height?: number + }) => ( +
+ {value} +
+ ), +})) + +const createFileOutput = (overrides: Partial = {}): FileOutput => ({ + dify_model_identity: '__dify__file__', + related_id: 'file-1', + extension: 'pdf', + filename: 'report.pdf', + size: 128, + mime_type: 'application/pdf', + transfer_method: TransferMethod.local_file, + type: 'document', + url: 'https://example.com/report.pdf', + upload_file_id: 'upload-1', + remote_url: '', + ...overrides, +}) + +describe('OutputPanel', () => { + it('renders the loading animation while the workflow is running', () => { + render() + + expect(screen.getByTestId('loading-anim')).toBeInTheDocument() + }) + + it('renders the failed status container when there is an error', () => { + render() + + expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed') + expect(screen.getByText('Execution failed')).toBeInTheDocument() + }) + + it('renders the no-output placeholder when there are no outputs', () => { + render() + + expect(screen.getByTestId('markdown')).toHaveTextContent('No Output') + }) + + it('renders a plain text output as markdown', () => { + render() + + expect(screen.getByTestId('markdown')).toHaveTextContent('Hello Dify') + }) + + it('renders array text outputs as joined markdown content', () => { + render() + + expect(screen.getByTestId('markdown')).toHaveTextContent(/Line 1\s+Line 2/) + }) + + it('renders a file list for a single file output', () => { + render() + + expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf') + }) + + it('renders a file list for an array of file outputs', () => { + render( + , + ) + + expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf, summary.md') + }) + + it('renders structured outputs inside the code editor when height is available', () => { + render() + + expect(screen.getByTestId('code-editor')).toHaveAttribute('data-language', 'json') + expect(screen.getByTestId('code-editor')).toHaveAttribute('data-height', '92') + expect(screen.getByTestId('code-editor')).toHaveAttribute('data-value', `{ + "answer": "hello", + "score": 1 +}`) + }) + + it('skips the code editor when structured outputs have no positive height', () => { + render() + + expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument() + }) +}) diff --git a/web/app/components/workflow/run/__tests__/result-text.spec.tsx b/web/app/components/workflow/run/__tests__/result-text.spec.tsx new file mode 100644 index 0000000000..9b0827c2f0 --- /dev/null +++ b/web/app/components/workflow/run/__tests__/result-text.spec.tsx @@ -0,0 +1,88 @@ +import type { FileEntity } from '@/app/components/base/file-uploader/types' +import { fireEvent, render, screen } from '@testing-library/react' +import { TransferMethod } from '@/types/app' +import ResultText from '../result-text' + +vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({ + default: () =>
, +})) + +vi.mock('@/app/components/base/file-uploader', () => ({ + FileList: ({ files }: { files: FileEntity[] }) => ( +
{files.map(file => file.name).join(', ')}
+ ), +})) + +vi.mock('@/app/components/base/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + +vi.mock('@/app/components/workflow/run/status-container', () => ({ + default: ({ status, children }: { status: string, children?: React.ReactNode }) => ( +
{children}
+ ), +})) + +describe('ResultText', () => { + it('renders the loading animation while waiting for a text result', () => { + render() + + expect(screen.getByTestId('loading-anim')).toBeInTheDocument() + }) + + it('renders the error state when the run fails', () => { + render() + + expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed') + expect(screen.getByText('Run failed')).toBeInTheDocument() + }) + + it('renders the empty-state call to action and forwards clicks', () => { + const onClick = vi.fn() + render() + + expect(screen.getByText('runLog.resultEmpty.title')).toBeInTheDocument() + + fireEvent.click(screen.getByText('runLog.resultEmpty.link')) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('does not render the empty state for paused runs', () => { + render() + + expect(screen.queryByText('runLog.resultEmpty.title')).not.toBeInTheDocument() + }) + + it('renders markdown content when text outputs are available', () => { + render() + + expect(screen.getByTestId('markdown')).toHaveTextContent('hello workflow') + }) + + it('renders file groups when file outputs are available', () => { + render( + , + ) + + expect(screen.getByText('attachments')).toBeInTheDocument() + expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf') + }) +}) diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx new file mode 100644 index 0000000000..25d3ceb278 --- /dev/null +++ b/web/app/components/workflow/run/__tests__/status.spec.tsx @@ -0,0 +1,131 @@ +import type { WorkflowPausedDetailsResponse } from '@/models/log' +import { render, screen } from '@testing-library/react' +import Status from '../status' + +const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`) +const mockUseWorkflowPausedDetails = vi.fn() + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => mockDocLink, +})) + +vi.mock('@/service/use-log', () => ({ + useWorkflowPausedDetails: (params: { workflowRunId: string, enabled?: boolean }) => mockUseWorkflowPausedDetails(params), +})) + +const createPausedDetails = (overrides: Partial = {}): WorkflowPausedDetailsResponse => ({ + paused_at: '2026-03-18T00:00:00Z', + paused_nodes: [], + ...overrides, +}) + +describe('Status', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined }) + }) + + it('renders the running status and loading placeholders', () => { + render() + + expect(screen.getByText('Running')).toBeInTheDocument() + expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2) + expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({ + workflowRunId: 'run-1', + enabled: false, + }) + }) + + it('renders the listening label when the run is waiting for input', () => { + render() + + expect(screen.getByText('Listening')).toBeInTheDocument() + }) + + it('renders succeeded metadata values', () => { + render() + + expect(screen.getByText('SUCCESS')).toBeInTheDocument() + expect(screen.getByText('1.234s')).toBeInTheDocument() + expect(screen.getByText('8 Tokens')).toBeInTheDocument() + }) + + it('renders stopped fallbacks when time and tokens are missing', () => { + render() + + expect(screen.getByText('STOP')).toBeInTheDocument() + expect(screen.getByText('-')).toBeInTheDocument() + expect(screen.getByText('0 Tokens')).toBeInTheDocument() + }) + + it('renders failed details and the partial-success exception tip', () => { + render() + + expect(screen.getByText('FAIL')).toBeInTheDocument() + expect(screen.getByText('Something broke')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":2}')).toBeInTheDocument() + }) + + it('renders the partial-succeeded warning summary', () => { + render() + + expect(screen.getByText('PARTIAL SUCCESS')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":3}')).toBeInTheDocument() + }) + + it('renders the exception learn-more link', () => { + render() + + const learnMoreLink = screen.getByRole('link', { name: 'workflow.common.learnMore' }) + + expect(screen.getByText('EXCEPTION')).toBeInTheDocument() + expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type') + expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type') + }) + + it('renders paused placeholders when pause details have not loaded yet', () => { + render() + + expect(screen.getByText('PENDING')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.humanInput.log.reason')).toBeInTheDocument() + expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3) + expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({ + workflowRunId: 'run-3', + enabled: true, + }) + }) + + it('renders paused human-input reasons and backstage URLs', () => { + mockUseWorkflowPausedDetails.mockReturnValue({ + data: createPausedDetails({ + paused_nodes: [ + { + node_id: 'node-1', + node_title: 'Need review', + pause_type: { + type: 'human_input', + form_id: 'form-1', + backstage_input_url: 'https://example.com/a', + }, + }, + { + node_id: 'node-2', + node_title: 'Need review 2', + pause_type: { + type: 'human_input', + form_id: 'form-2', + backstage_input_url: 'https://example.com/b', + }, + }, + ], + }), + }) + + render() + + expect(screen.getByText('workflow.nodes.humanInput.log.reasonContent')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.humanInput.log.backstageInputURL')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'https://example.com/a' })).toHaveAttribute('href', 'https://example.com/a') + expect(screen.getByRole('link', { name: 'https://example.com/b' })).toHaveAttribute('href', 'https://example.com/b') + }) +}) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx new file mode 100644 index 0000000000..b4e06676cd --- /dev/null +++ b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx @@ -0,0 +1,84 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { render, screen, waitFor } from '@testing-library/react' +import ReactFlow, { ReactFlowProvider } from 'reactflow' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' +import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' +import ErrorHandleOnNode from '../error-handle-on-node' + +const createNodeData = (overrides: Partial = {}): CommonNodeType => ({ + type: BlockEnum.Code, + title: 'Node', + desc: '', + ...overrides, +}) + +const ErrorNode = ({ id, data }: NodeProps) => ( +
+ +
+) + +const renderErrorNode = (data: CommonNodeType) => { + return render( +
+ + + +
, + ) +} + +describe('ErrorHandleOnNode', () => { + // Empty and default-value states. + describe('Rendering', () => { + it('should render nothing when the node has no error strategy', () => { + const { container } = renderErrorNode(createNodeData()) + + expect(screen.queryByText('workflow.common.onFailure')).not.toBeInTheDocument() + expect(container.querySelector('.react-flow__handle')).not.toBeInTheDocument() + }) + + it('should render the default-value label', async () => { + renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.defaultValue })) + + await waitFor(() => expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()) + expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument() + expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument() + }) + }) + + // Fail-branch behavior and warning styling. + describe('Effects', () => { + it('should render the fail-branch source handle', async () => { + const { container } = renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.failBranch })) + + await waitFor(() => expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()) + expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument() + expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch) + }) + + it('should add warning styles when the node is in exception status', async () => { + const { container } = renderErrorNode(createNodeData({ + error_strategy: ErrorHandleTypeEnum.defaultValue, + _runningStatus: NodeRunningStatus.Exception, + })) + + await waitFor(() => expect(container.querySelector('.bg-state-warning-hover')).toBeInTheDocument()) + expect(container.querySelector('.bg-state-warning-hover')).toHaveClass('border-components-badge-status-light-warning-halo') + expect(container.querySelector('.text-text-warning')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx new file mode 100644 index 0000000000..a354ee9afb --- /dev/null +++ b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx @@ -0,0 +1,130 @@ +import type { NodeProps } from 'reactflow' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { render, waitFor } from '@testing-library/react' +import ReactFlow, { ReactFlowProvider } from 'reactflow' +import { BlockEnum } from '@/app/components/workflow/types' +import { NodeSourceHandle, NodeTargetHandle } from '../node-handle' + +const createNodeData = (overrides: Partial = {}): CommonNodeType => ({ + type: BlockEnum.Code, + title: 'Node', + desc: '', + ...overrides, +}) + +const TargetHandleNode = ({ id, data }: NodeProps) => ( +
+ +
+) + +const SourceHandleNode = ({ id, data }: NodeProps) => ( +
+ +
+) + +const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => { + return render( +
+ + + +
, + ) +} + +describe('node-handle', () => { + // Target handle states and visibility rules. + describe('NodeTargetHandle', () => { + it('should hide the connection indicator when the target handle is not connected', async () => { + const { container } = renderFlowNode('targetNode', createNodeData()) + + await waitFor(() => expect(container.querySelector('.target-marker')).toBeInTheDocument()) + + const handle = container.querySelector('.target-marker') + + expect(handle).toHaveAttribute('data-handleid', 'target-1') + expect(handle).toHaveClass('after:opacity-0') + }) + + it('should merge custom classes and hide start-like nodes completely', async () => { + const { container } = render( +
+ + ) => ( +
+ +
+ ), + }} + /> +
+
, + ) + + await waitFor(() => expect(container.querySelector('.custom-target')).toBeInTheDocument()) + + const handle = container.querySelector('.custom-target') + + expect(handle).toHaveClass('opacity-0') + expect(handle).toHaveClass('custom-target') + }) + }) + + // Source handle connection state. + describe('NodeSourceHandle', () => { + it('should keep the source indicator visible when the handle is connected', async () => { + const { container } = renderFlowNode('sourceNode', createNodeData({ _connectedSourceHandleIds: ['source-1'] })) + + await waitFor(() => expect(container.querySelector('.source-marker')).toBeInTheDocument()) + + const handle = container.querySelector('.source-marker') + + expect(handle).toHaveAttribute('data-handleid', 'source-1') + expect(handle).not.toHaveClass('after:opacity-0') + }) + }) +}) diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index d457409d29..19ef26814d 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -2,10 +2,6 @@ import { RiExternalLinkLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import { - useRouter, - useSearchParams, -} from 'next/navigation' import { useState, } from 'react' @@ -16,6 +12,10 @@ import { useToastContext } from '@/app/components/base/toast/context' import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants' import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' +import { + useRouter, + useSearchParams, +} from '@/next/navigation' import { useEducationAdd, useInvalidateEducationStatus, diff --git a/web/app/education-apply/expire-notice-modal.tsx b/web/app/education-apply/expire-notice-modal.tsx index 2b96ecba88..fc939ffc3c 100644 --- a/web/app/education-apply/expire-notice-modal.tsx +++ b/web/app/education-apply/expire-notice-modal.tsx @@ -1,7 +1,5 @@ 'use client' import { RiExternalLinkLine } from '@remixicon/react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' import * as React from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -9,6 +7,8 @@ import Modal from '@/app/components/base/modal' import { useDocLink } from '@/context/i18n' import { useModalContextSelector } from '@/context/modal-context' import useTimestamp from '@/hooks/use-timestamp' +import Link from '@/next/link' +import { useRouter } from '@/next/navigation' import { useEducationVerify } from '@/service/use-education' import { SparklesSoftAccent } from '../components/base/icons/src/public/common' diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts index 52acde2975..79faa8b3b2 100644 --- a/web/app/education-apply/hooks.ts +++ b/web/app/education-apply/hooks.ts @@ -3,7 +3,6 @@ import { useDebounceFn, useLocalStorageState } from 'ahooks' import dayjs from 'dayjs' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, @@ -13,6 +12,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con import { useAppContext } from '@/context/app-context' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { useRouter, useSearchParams } from '@/next/navigation' import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education' import { EDUCATION_RE_VERIFY_ACTION, diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx index 6481194870..dc10af7e3c 100644 --- a/web/app/education-apply/user-info.tsx +++ b/web/app/education-apply/user-info.tsx @@ -1,9 +1,9 @@ -import { useRouter } from 'next/navigation' import { useTranslation } from 'react-i18next' import { Avatar } from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import { Triangle } from '@/app/components/base/icons/src/public/education' import { useAppContext } from '@/context/app-context' +import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' const UserInfo = () => { diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx index 880b010d9c..e586148d9e 100644 --- a/web/app/forgot-password/ChangePasswordForm.tsx +++ b/web/app/forgot-password/ChangePasswordForm.tsx @@ -1,12 +1,12 @@ 'use client' import { CheckCircleIcon } from '@heroicons/react/24/solid' -import { useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' +import { useSearchParams } from '@/next/navigation' import { changePasswordWithToken } from '@/service/common' import { useVerifyForgotPasswordToken } from '@/service/use-common' import { cn } from '@/utils/classnames' @@ -29,9 +29,9 @@ const ChangePasswordForm = () => { const [showSuccess, setShowSuccess] = useState(false) const showErrorMessage = useCallback((message: string) => { - Toast.notify({ + toast.add({ type: 'error', - message, + title: message, }) }, []) diff --git a/web/app/forgot-password/ForgotPasswordForm.spec.tsx b/web/app/forgot-password/ForgotPasswordForm.spec.tsx index aa360cb6c3..8ed120d146 100644 --- a/web/app/forgot-password/ForgotPasswordForm.spec.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.spec.tsx @@ -5,7 +5,7 @@ import ForgotPasswordForm from './ForgotPasswordForm' const mockPush = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush }), })) diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index 274c2fd4e6..fdc35c20da 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -2,15 +2,15 @@ import type { InitValidateStatusResponse } from '@/models/common' import { useStore } from '@tanstack/react-form' -import { useRouter } from 'next/navigation' - import * as React from 'react' + import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import * as z from 'zod' import Button from '@/app/components/base/button' import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' +import { useRouter } from '@/next/navigation' import { fetchInitValidateStatus, fetchSetupStatus, diff --git a/web/app/forgot-password/page.tsx b/web/app/forgot-password/page.tsx index 338f4eaf13..7014b9e5b6 100644 --- a/web/app/forgot-password/page.tsx +++ b/web/app/forgot-password/page.tsx @@ -1,9 +1,9 @@ 'use client' -import { useSearchParams } from 'next/navigation' import * as React from 'react' import ChangePasswordForm from '@/app/forgot-password/ChangePasswordForm' import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import { useSearchParams } from '@/next/navigation' import { cn } from '@/utils/classnames' import Header from '../signin/_header' import ForgotPasswordForm from './ForgotPasswordForm' diff --git a/web/app/init/InitPasswordPopup.tsx b/web/app/init/InitPasswordPopup.tsx index c8598881a3..d2ec3c7e2b 100644 --- a/web/app/init/InitPasswordPopup.tsx +++ b/web/app/init/InitPasswordPopup.tsx @@ -1,10 +1,10 @@ 'use client' import type { InitValidateStatusResponse } from '@/models/common' -import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import useDocumentTitle from '@/hooks/use-document-title' +import { useRouter } from '@/next/navigation' import { fetchInitValidateStatus, initValidate } from '@/service/common' import { basePath } from '@/utils/var' import Loading from '../components/base/loading' diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx index 17ce35d6a1..1286d02343 100644 --- a/web/app/install/installForm.spec.tsx +++ b/web/app/install/installForm.spec.tsx @@ -7,7 +7,7 @@ import InstallForm from './installForm' const mockPush = vi.fn() const mockReplace = vi.fn() -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: mockPush, replace: mockReplace }), })) diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index 47de6d1fb3..292a922723 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -1,10 +1,8 @@ 'use client' import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common' import { useStore } from '@tanstack/react-form' -import Link from 'next/link' - -import { useRouter } from 'next/navigation' import * as React from 'react' + import { useEffect } from 'react' import { useTranslation } from 'react-i18next' import * as z from 'zod' @@ -13,9 +11,11 @@ import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' import Input from '@/app/components/base/input' import { validPassword } from '@/config' - import { LICENSE_LINK } from '@/constants/link' import useDocumentTitle from '@/hooks/use-document-title' + +import Link from '@/next/link' +import { useRouter } from '@/next/navigation' import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common' import { cn } from '@/utils/classnames' import { encryptPassword as encodePassword } from '@/utils/encryption' diff --git a/web/app/page.tsx b/web/app/page.tsx index 117d6c838d..65f8827e01 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,5 +1,5 @@ -import Link from 'next/link' import Loading from '@/app/components/base/loading' +import Link from '@/next/link' const Home = async () => { return ( diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx index cf4a6e6ce4..e4a630ab11 100644 --- a/web/app/reset-password/check-code/page.tsx +++ b/web/app/reset-password/check-code/page.tsx @@ -1,13 +1,13 @@ 'use client' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' +import { useRouter, useSearchParams } from '@/next/navigation' import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common' export default function CheckCode() { @@ -23,16 +23,16 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.emptyCode', { ns: 'login' }), + title: t('checkCode.emptyCode', { ns: 'login' }), }) return } if (!/\d{6}/.test(code)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.invalidCode', { ns: 'login' }), + title: t('checkCode.invalidCode', { ns: 'login' }), }) return } diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx index 9fdccdfd87..03ec54434b 100644 --- a/web/app/reset-password/page.tsx +++ b/web/app/reset-password/page.tsx @@ -1,16 +1,16 @@ 'use client' import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' import useDocumentTitle from '@/hooks/use-document-title' +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { sendResetPasswordCode } from '@/service/common' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown' @@ -26,14 +26,14 @@ export default function CheckCode() { const handleGetEMailVerificationCode = async () => { try { if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } @@ -47,9 +47,9 @@ export default function CheckCode() { router.push(`/reset-password/check-code?${params.toString()}`) } else { - Toast.notify({ + toast.add({ type: 'error', - message: res.data, + title: res.data, }) } } diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx index bf7947c79d..26c301d1df 100644 --- a/web/app/reset-password/set-password/page.tsx +++ b/web/app/reset-password/set-password/page.tsx @@ -1,13 +1,13 @@ 'use client' import { RiCheckboxCircleFill } from '@remixicon/react' import { useCountDown } from 'ahooks' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' +import { useRouter, useSearchParams } from '@/next/navigation' import { changePasswordWithToken } from '@/service/common' import { cn } from '@/utils/classnames' @@ -24,9 +24,9 @@ const ChangePasswordForm = () => { const [showConfirmPassword, setShowConfirmPassword] = useState(false) const showErrorMessage = useCallback((message: string) => { - Toast.notify({ + toast.add({ type: 'error', - message, + title: message, }) }, []) diff --git a/web/app/routePrefixHandle.tsx b/web/app/routePrefixHandle.tsx index d3a36a51fc..e772c7964a 100644 --- a/web/app/routePrefixHandle.tsx +++ b/web/app/routePrefixHandle.tsx @@ -1,7 +1,7 @@ 'use client' -import { usePathname } from 'next/navigation' import { useEffect } from 'react' +import { usePathname } from '@/next/navigation' import { basePath } from '@/utils/var' export default function RoutePrefixHandle() { diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index 24ac92157e..650c401804 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -1,16 +1,16 @@ 'use client' import type { FormEvent } from 'react' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' - import { useLocale } from '@/context/i18n' + +import { useRouter, useSearchParams } from '@/next/navigation' import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common' import { encryptVerificationCode } from '@/utils/encryption' import { resolvePostLoginRedirect } from '../utils/post-login-redirect' @@ -31,16 +31,16 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.emptyCode', { ns: 'login' }), + title: t('checkCode.emptyCode', { ns: 'login' }), }) return } if (!/\d{6}/.test(code)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.invalidCode', { ns: 'login' }), + title: t('checkCode.invalidCode', { ns: 'login' }), }) return } diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx index 4454fc821f..e3acc0e4ba 100644 --- a/web/app/signin/components/mail-and-code-auth.tsx +++ b/web/app/signin/components/mail-and-code-auth.tsx @@ -1,13 +1,13 @@ import type { FormEvent } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' +import { useRouter, useSearchParams } from '@/next/navigation' import { sendEMailLoginCode } from '@/service/common' type MailAndCodeAuthProps = { @@ -26,14 +26,14 @@ export default function MailAndCodeAuth({ isInvite }: MailAndCodeAuthProps) { const handleGetEMailVerificationCode = async () => { try { if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index 877720b691..e12c3da4df 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -1,15 +1,15 @@ import type { ResponseError } from '@/service/fetch' import { noop } from 'es-toolkit/function' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { emailRegex } from '@/config' import { useLocale } from '@/context/i18n' +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { login } from '@/service/common' import { setWebAppAccessToken } from '@/service/webapp-auth' import { encryptPassword } from '@/utils/encryption' @@ -35,18 +35,18 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis const handleEmailPasswordLogin = async () => { if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } if (!password?.trim()) { - Toast.notify({ type: 'error', message: t('error.passwordEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.passwordEmpty', { ns: 'login' }) }) return } @@ -83,17 +83,17 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis } } else { - Toast.notify({ + toast.add({ type: 'error', - message: res.data, + title: res.data, }) } } catch (error) { if ((error as ResponseError).code === 'authentication_failed') { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.invalidEmailOrPassword', { ns: 'login' }), + title: t('error.invalidEmailOrPassword', { ns: 'login' }), }) } } diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx index 8a610bf093..35517a0505 100644 --- a/web/app/signin/components/social-auth.tsx +++ b/web/app/signin/components/social-auth.tsx @@ -1,7 +1,7 @@ -import { useSearchParams } from 'next/navigation' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { API_PREFIX } from '@/config' +import { useSearchParams } from '@/next/navigation' import { getPurifyHref } from '@/utils' import { cn } from '@/utils/classnames' import style from '../page.module.css' diff --git a/web/app/signin/components/sso-auth.tsx b/web/app/signin/components/sso-auth.tsx index 43d5d2dfe8..a7bc413665 100644 --- a/web/app/signin/components/sso-auth.tsx +++ b/web/app/signin/components/sso-auth.tsx @@ -1,11 +1,11 @@ 'use client' import type { FC } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' +import { useRouter, useSearchParams } from '@/next/navigation' import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso' import { SSOProtocol } from '@/types/feature' @@ -49,9 +49,9 @@ const SSOAuth: FC = ({ }) } else { - Toast.notify({ + toast.add({ type: 'error', - message: 'invalid SSO protocol', + title: t('error.invalidSSOProtocol', { ns: 'login' }), }) setIsLoading(false) } diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 915e85ce57..ac7a7191f8 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -2,8 +2,6 @@ import type { Locale } from '@/i18n-config' import { RiAccountCircleLine } from '@remixicon/react' import { noop } from 'es-toolkit/function' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -15,6 +13,8 @@ import { LICENSE_LINK } from '@/constants/link' import { useGlobalPublicStore } from '@/context/global-public-context' import { setLocaleOnClient } from '@/i18n-config' import { languages, LanguagesSupported } from '@/i18n-config/language' +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { activateMember } from '@/service/common' import { useInvitationCheck } from '@/service/use-common' import { timezones } from '@/utils/timezone' diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index 15d86f482c..fa0d3c8078 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -1,12 +1,12 @@ import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { IS_CE_EDITION } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { invitationCheck } from '@/service/common' import { useIsLogin } from '@/service/use-common' import { LicenseStatus } from '@/types/feature' @@ -48,9 +48,9 @@ const NormalForm = () => { } if (message) { - Toast.notify({ + toast.add({ type: 'error', - message, + title: message, }) } setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin) diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx index ff28b3caaf..1d632e272c 100644 --- a/web/app/signin/one-more-step.tsx +++ b/web/app/signin/one-more-step.tsx @@ -1,7 +1,5 @@ 'use client' import type { Reducer } from 'react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' import { useReducer } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' @@ -10,6 +8,8 @@ import Toast from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' import { LICENSE_LINK } from '@/constants/link' import { languages, LanguagesSupported } from '@/i18n-config/language' +import Link from '@/next/link' +import { useRouter, useSearchParams } from '@/next/navigation' import { useOneMoreStep } from '@/service/use-common' import { timezones } from '@/utils/timezone' import Input from '../components/base/input' diff --git a/web/app/signin/page.tsx b/web/app/signin/page.tsx index 6f3632393c..7fad92fe5d 100644 --- a/web/app/signin/page.tsx +++ b/web/app/signin/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useSearchParams } from 'next/navigation' import { useEffect } from 'react' +import { useSearchParams } from '@/next/navigation' import usePSInfo from '../components/billing/partner-stack/use-ps-info' import NormalForm from './normal-form' import OneMoreStep from './one-more-step' diff --git a/web/app/signup/check-code/page.tsx b/web/app/signup/check-code/page.tsx index c298c11535..f4cc272e5a 100644 --- a/web/app/signup/check-code/page.tsx +++ b/web/app/signup/check-code/page.tsx @@ -1,14 +1,14 @@ 'use client' import type { MailSendResponse, MailValidityResponse } from '@/service/use-common' import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react' -import { useRouter, useSearchParams } from 'next/navigation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Countdown from '@/app/components/signin/countdown' import { useLocale } from '@/context/i18n' +import { useRouter, useSearchParams } from '@/next/navigation' import { useMailValidity, useSendMail } from '@/service/use-common' export default function CheckCode() { @@ -26,16 +26,16 @@ export default function CheckCode() { const verify = async () => { try { if (!code.trim()) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.emptyCode', { ns: 'login' }), + title: t('checkCode.emptyCode', { ns: 'login' }), }) return } if (!/\d{6}/.test(code)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.invalidCode', { ns: 'login' }), + title: t('checkCode.invalidCode', { ns: 'login' }), }) return } @@ -47,9 +47,9 @@ export default function CheckCode() { router.push(`/signup/set-password?${params.toString()}`) } else { - Toast.notify({ + toast.add({ type: 'error', - message: t('checkCode.invalidCode', { ns: 'login' }), + title: t('checkCode.invalidCode', { ns: 'login' }), }) } } diff --git a/web/app/signup/components/input-mail.spec.tsx b/web/app/signup/components/input-mail.spec.tsx index d5acc92153..e16c381585 100644 --- a/web/app/signup/components/input-mail.spec.tsx +++ b/web/app/signup/components/input-mail.spec.tsx @@ -24,7 +24,7 @@ const buildSystemFeatures = (overrides: SystemFeaturesOverrides = {}): SystemFea }, }) -vi.mock('next/link', () => ({ +vi.mock('@/next/link', () => ({ default: ({ children, href, className, target, rel }: { children: React.ReactNode, href: string, className?: string, target?: string, rel?: string }) => ( {children} diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx index 1b88007ce4..3f26202965 100644 --- a/web/app/signup/components/input-mail.tsx +++ b/web/app/signup/components/input-mail.tsx @@ -1,15 +1,15 @@ 'use client' import type { MailSendResponse } from '@/service/use-common' -import Link from 'next/link' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import Split from '@/app/signin/split' import { emailRegex } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' import { useLocale } from '@/context/i18n' +import Link from '@/next/link' import { useSendMail } from '@/service/use-common' type Props = { @@ -30,13 +30,13 @@ export default function Form({ return if (!email) { - Toast.notify({ type: 'error', message: t('error.emailEmpty', { ns: 'login' }) }) + toast.add({ type: 'error', title: t('error.emailEmpty', { ns: 'login' }) }) return } if (!emailRegex.test(email)) { - Toast.notify({ + toast.add({ type: 'error', - message: t('error.emailInValid', { ns: 'login' }), + title: t('error.emailInValid', { ns: 'login' }), }) return } diff --git a/web/app/signup/page.tsx b/web/app/signup/page.tsx index a5a8fb40a7..da821ae50e 100644 --- a/web/app/signup/page.tsx +++ b/web/app/signup/page.tsx @@ -1,7 +1,7 @@ 'use client' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useRouter, useSearchParams } from '@/next/navigation' import MailForm from './components/input-mail' const Signup = () => { diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx index 69af045f1a..42ffb0843d 100644 --- a/web/app/signup/set-password/page.tsx +++ b/web/app/signup/set-password/page.tsx @@ -1,14 +1,14 @@ 'use client' import type { MailRegisterResponse } from '@/service/use-common' import Cookies from 'js-cookie' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { validPassword } from '@/config' +import { useRouter, useSearchParams } from '@/next/navigation' import { useMailRegister } from '@/service/use-common' import { cn } from '@/utils/classnames' import { sendGAEvent } from '@/utils/gtag' @@ -37,9 +37,9 @@ const ChangePasswordForm = () => { const { mutateAsync: register, isPending } = useMailRegister() const showErrorMessage = useCallback((message: string) => { - Toast.notify({ + toast.add({ type: 'error', - message, + title: message, }) }, []) @@ -82,9 +82,9 @@ const ChangePasswordForm = () => { }) Cookies.remove('utm_info') // Clean up: remove utm_info cookie - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) router.replace('/apps') } diff --git a/web/config/index.ts b/web/config/index.ts index e8526479a1..3f7d26c623 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -281,8 +281,7 @@ Thought: {{agent_scratchpad}} `, } -export const VAR_REGEX - = /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi +export const VAR_REGEX = /\{\{(#[\w-]{1,50}(\.\d+)?(\.[a-z_]\w{0,29}){1,10}#)\}\}/gi export const resetReg = () => (VAR_REGEX.lastIndex = 0) diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index 98f67a5473..6fac2e0cd5 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -13,7 +13,7 @@ vi.mock('@/config', async (importOriginal) => { } }) -vi.mock('next/navigation', () => ({ +vi.mock('@/next/navigation', () => ({ useSearchParams: vi.fn(() => new URLSearchParams()), })) diff --git a/web/context/provider-context-provider.tsx b/web/context/provider-context-provider.tsx index ce7f2ba40c..0101dc69c8 100644 --- a/web/context/provider-context-provider.tsx +++ b/web/context/provider-context-provider.tsx @@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' import { defaultPlan } from '@/app/components/billing/config' import { parseCurrentPlan } from '@/app/components/billing/utils' @@ -132,13 +132,11 @@ export const ProviderContextProvider = ({ if (anthropic && anthropic.system_configuration.current_quota_type === CurrentSystemQuotaTypeEnum.trial) { const quota = anthropic.system_configuration.quota_configurations.find(item => item.quota_type === anthropic.system_configuration.current_quota_type) if (quota && quota.is_valid && quota.quota_used < quota.quota_limit) { - Toast.notify({ + localStorage.setItem('anthropic_quota_notice', 'true') + toast.add({ type: 'info', - message: t('provider.anthropicHosted.trialQuotaTip', { ns: 'common' }), - duration: 60000, - onClose: () => { - localStorage.setItem('anthropic_quota_notice', 'true') - }, + title: t('provider.anthropicHosted.trialQuotaTip', { ns: 'common' }), + timeout: 60000, }) } } diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx index c5488a565c..33679fd44f 100644 --- a/web/context/web-app-context.tsx +++ b/web/context/web-app-context.tsx @@ -3,12 +3,12 @@ import type { FC, PropsWithChildren } from 'react' import type { ChatConfig } from '@/app/components/base/chat/types' import type { AppData, AppMeta } from '@/models/share' -import { usePathname, useSearchParams } from 'next/navigation' import { useEffect } from 'react' import { create } from 'zustand' import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils' import Loading from '@/app/components/base/loading' import { AccessMode } from '@/models/access-control' +import { usePathname, useSearchParams } from '@/next/navigation' import { useGetWebAppAccessModeByCode } from '@/service/use-share' import { useIsSystemFeaturesPending } from './global-public-context' diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 6671296efa..218ff71721 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -189,9 +189,6 @@ } }, "app/(shareLayout)/webapp-reset-password/check-code/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } @@ -205,46 +202,26 @@ } }, "app/(shareLayout)/webapp-reset-password/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } }, "app/(shareLayout)/webapp-reset-password/set-password/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 6 } }, "app/(shareLayout)/webapp-signin/check-code/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } }, - "app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/(shareLayout)/webapp-signin/components/mail-and-code-auth.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, "app/(shareLayout)/webapp-signin/components/mail-and-password-auth.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 2 }, @@ -252,11 +229,6 @@ "count": 2 } }, - "app/(shareLayout)/webapp-signin/components/sso-auth.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/(shareLayout)/webapp-signin/layout.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -335,9 +307,6 @@ } }, "app/account/oauth/authorize/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -1127,9 +1096,6 @@ } }, "app/components/app/create-app-dialog/app-list/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 5 } @@ -1845,7 +1811,7 @@ "count": 2 }, "tailwindcss/enforce-consistent-class-order": { - "count": 2 + "count": 1 } }, "app/components/base/content-dialog/index.stories.tsx": { @@ -2924,14 +2890,6 @@ "count": 1 } }, - "app/components/billing/pricing/plans/cloud-plan-item/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 6 - } - }, "app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -2947,17 +2905,6 @@ "count": 1 } }, - "app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 4 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 - } - }, "app/components/billing/pricing/plans/self-hosted-plan-item/list/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -3786,14 +3733,6 @@ "count": 1 } }, - "app/components/datasets/documents/detail/completed/new-child-segment.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/documents/detail/completed/segment-card/chunk-content.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 2 @@ -3862,14 +3801,6 @@ "count": 1 } }, - "app/components/datasets/documents/detail/new-segment.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/datasets/documents/detail/segment-add/index.tsx": { "no-restricted-imports": { "count": 1 @@ -3930,11 +3861,6 @@ "count": 1 } }, - "app/components/datasets/external-knowledge-base/connector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -4382,11 +4308,6 @@ "count": 2 } }, - "app/components/explore/sidebar/index.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "app/components/explore/sidebar/no-apps/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 3 @@ -4859,11 +4780,6 @@ "count": 1 } }, - "app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/components/header/account-setting/plugin-page/SerpapiPlugin.tsx": { "no-restricted-imports": { "count": 1 @@ -5027,11 +4943,6 @@ "count": 1 } }, - "app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 1 @@ -5048,11 +4959,6 @@ "count": 1 } }, - "app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 2 - } - }, "app/components/plugins/marketplace/description/index.tsx": { "tailwindcss/enforce-consistent-class-order": { "count": 9 @@ -5063,6 +4969,11 @@ "count": 1 } }, + "app/components/plugins/marketplace/hooks.ts": { + "@tanstack/query/exhaustive-deps": { + "count": 1 + } + }, "app/components/plugins/marketplace/list/card-wrapper.tsx": { "tailwindcss/no-unnecessary-whitespace": { "count": 1 @@ -5233,9 +5144,6 @@ "app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 2 } }, "app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx": { @@ -5246,9 +5154,6 @@ "app/components/plugins/plugin-detail-panel/app-selector/index.tsx": { "no-restricted-imports": { "count": 1 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 } }, "app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": { @@ -5394,17 +5299,6 @@ "count": 3 } }, - "app/components/plugins/plugin-detail-panel/subscription-list/delete-confirm.tsx": { - "no-restricted-imports": { - "count": 2 - }, - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/plugins/plugin-detail-panel/subscription-list/edit/apikey-edit-modal.tsx": { "no-restricted-imports": { "count": 2 @@ -5542,9 +5436,6 @@ "no-restricted-imports": { "count": 1 }, - "tailwindcss/enforce-consistent-class-order": { - "count": 7 - }, "ts/no-explicit-any": { "count": 1 } @@ -6602,11 +6493,6 @@ "count": 1 } }, - "app/components/workflow/header/scroll-to-selected-node-button.tsx": { - "tailwindcss/enforce-consistent-class-order": { - "count": 1 - } - }, "app/components/workflow/header/test-run-menu.tsx": { "no-restricted-imports": { "count": 1 @@ -7110,11 +6996,6 @@ "count": 5 } }, - "app/components/workflow/nodes/_base/components/variable/output-var-list.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "app/components/workflow/nodes/_base/components/variable/utils.ts": { "ts/no-explicit-any": { "count": 32 @@ -7128,11 +7009,6 @@ "count": 1 } }, - "app/components/workflow/nodes/_base/components/variable/var-list.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx": { "no-restricted-imports": { "count": 2 @@ -8882,9 +8758,6 @@ } }, "app/components/workflow/panel/version-history-panel/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 2 } @@ -9376,11 +9249,6 @@ "count": 5 } }, - "app/forgot-password/ChangePasswordForm.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/forgot-password/ForgotPasswordForm.spec.tsx": { "ts/no-explicit-any": { "count": 5 @@ -9405,9 +9273,6 @@ } }, "app/reset-password/check-code/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } @@ -9421,17 +9286,11 @@ } }, "app/reset-password/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } }, "app/reset-password/set-password/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 6 } @@ -9441,32 +9300,16 @@ "count": 1 } }, - "app/signin/check-code/page.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/signin/components/mail-and-code-auth.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, "app/signin/components/mail-and-password-auth.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } }, - "app/signin/components/sso-auth.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/signin/invite-settings/page.tsx": { "no-restricted-imports": { "count": 2 @@ -9480,11 +9323,6 @@ "count": 1 } }, - "app/signin/normal-form.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "app/signin/one-more-step.tsx": { "no-restricted-imports": { "count": 3 @@ -9497,17 +9335,11 @@ } }, "app/signup/check-code/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } }, "app/signup/components/input-mail.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 4 } @@ -9526,9 +9358,6 @@ } }, "app/signup/set-password/page.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 5 } @@ -9569,9 +9398,6 @@ } }, "context/provider-context-provider.tsx": { - "no-restricted-imports": { - "count": 1 - }, "ts/no-explicit-any": { "count": 1 } @@ -9723,6 +9549,11 @@ "count": 6 } }, + "service/access-control.ts": { + "@tanstack/query/exhaustive-deps": { + "count": 1 + } + }, "service/annotation.ts": { "ts/no-explicit-any": { "count": 4 @@ -9757,9 +9588,6 @@ } }, "service/fetch.ts": { - "no-restricted-imports": { - "count": 1 - }, "regexp/no-unused-capturing-group": { "count": 1 }, @@ -9767,6 +9595,11 @@ "count": 2 } }, + "service/knowledge/use-dataset.ts": { + "@tanstack/query/exhaustive-deps": { + "count": 1 + } + }, "service/share.ts": { "ts/no-explicit-any": { "count": 3 @@ -9791,6 +9624,9 @@ } }, "service/use-pipeline.ts": { + "@tanstack/query/exhaustive-deps": { + "count": 2 + }, "ts/no-explicit-any": { "count": 1 } @@ -9817,6 +9653,9 @@ } }, "service/use-workflow.ts": { + "@tanstack/query/exhaustive-deps": { + "count": 1 + }, "ts/no-explicit-any": { "count": 3 } diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 4599778eee..ee26de85e9 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -5,7 +5,13 @@ import tailwindcss from 'eslint-plugin-better-tailwindcss' import hyoban from 'eslint-plugin-hyoban' import sonar from 'eslint-plugin-sonarjs' import storybook from 'eslint-plugin-storybook' -import { OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs' +import { + HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS, + NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS, + NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS, + OVERLAY_MIGRATION_LEGACY_BASE_FILES, + OVERLAY_RESTRICTED_IMPORT_PATTERNS, +} from './eslint.constants.mjs' import dify from './plugins/eslint/index.js' // Enable Tailwind CSS IntelliSense mode for ESLint runs @@ -14,111 +20,6 @@ process.env.TAILWIND_MODE ??= 'ESLINT' const disableRuleAutoFix = !(isInEditorEnv() || isInGitHooksOrLintStaged()) -const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [ - { - name: 'next', - message: 'Import Next APIs from @/next instead of next.', - }, -] - -const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [ - { - group: ['next/image'], - message: 'Do not import next/image. Use native img tags instead.', - }, - { - group: ['next/font', 'next/font/*'], - message: 'Do not import next/font. Use the project font styles instead.', - }, - { - group: ['next/dynamic'], - message: 'Import Next APIs from @/next/dynamic instead of next/dynamic.', - }, - { - group: ['next/headers'], - message: 'Import Next APIs from @/next/headers instead of next/headers.', - }, - { - group: ['next/script'], - message: 'Import Next APIs from @/next/script instead of next/script.', - }, - { - group: ['next/server'], - message: 'Import Next APIs from @/next/server instead of next/server.', - }, -] - -const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ - { - group: [ - '**/portal-to-follow-elem', - '**/portal-to-follow-elem/index', - ], - message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.', - }, - { - group: [ - '**/base/tooltip', - '**/base/tooltip/index', - ], - message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.', - }, - { - group: [ - '**/base/modal', - '**/base/modal/index', - '**/base/modal/modal', - ], - message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/select', - '**/base/select/index', - '**/base/select/custom', - '**/base/select/pure', - ], - message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.', - }, - { - group: [ - '**/base/confirm', - '**/base/confirm/index', - ], - message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/popover', - '**/base/popover/index', - ], - message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.', - }, - { - group: [ - '**/base/dropdown', - '**/base/dropdown/index', - ], - message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.', - }, - { - group: [ - '**/base/dialog', - '**/base/dialog/index', - ], - message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/toast', - '**/base/toast/index', - '**/base/toast/context', - '**/base/toast/context/index', - ], - message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.', - }, -] - export default antfu( { react: { @@ -204,37 +105,7 @@ export default antfu( { files: ['**/*.tsx'], rules: { - 'hyoban/prefer-tailwind-icons': ['warn', { - prefix: 'i-', - propMappings: { - size: 'size', - width: 'w', - height: 'h', - }, - libraries: [ - { - prefix: 'i-custom-', - source: '^@/app/components/base/icons/src/(?(?:public|vender)(?:/.*)?)$', - name: '^(?.*)$', - }, - { - source: '^@remixicon/react$', - name: '^(?Ri)(?.+)$', - }, - { - source: '^@(?heroicons)/react/24/outline$', - name: '^(?.*)Icon$', - }, - { - source: '^@(?heroicons)/react/24/(?solid)$', - name: '^(?.*)Icon$', - }, - { - source: '^@(?heroicons)/react/(?\\d+/(?:solid|outline))$', - name: '^(?.*)Icon$', - }, - ], - }], + 'hyoban/prefer-tailwind-icons': ['warn', HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS], }, }, { diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index 2ec571de84..ce19b99c9b 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -1,3 +1,96 @@ +export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [ + { + name: 'next', + message: 'Import Next APIs from the corresponding @/next module instead of next.', + }, +] + +export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [ + { + group: ['next/image'], + message: 'Do not import next/image. Use native img tags instead.', + }, + { + group: ['next/font', 'next/font/*'], + message: 'Do not import next/font. Use the project font styles instead.', + }, + { + group: ['next/*', '!next/font', '!next/font/*', '!next/image', '!next/image/*'], + message: 'Import Next APIs from the corresponding @/next/* module instead of next/*.', + }, +] + +export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ + { + group: [ + '**/portal-to-follow-elem', + '**/portal-to-follow-elem/index', + ], + message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.', + }, + { + group: [ + '**/base/tooltip', + '**/base/tooltip/index', + ], + message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.', + }, + { + group: [ + '**/base/modal', + '**/base/modal/index', + '**/base/modal/modal', + ], + message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', + }, + { + group: [ + '**/base/select', + '**/base/select/index', + '**/base/select/custom', + '**/base/select/pure', + ], + message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.', + }, + { + group: [ + '**/base/confirm', + '**/base/confirm/index', + ], + message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.', + }, + { + group: [ + '**/base/popover', + '**/base/popover/index', + ], + message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.', + }, + { + group: [ + '**/base/dropdown', + '**/base/dropdown/index', + ], + message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.', + }, + { + group: [ + '**/base/dialog', + '**/base/dialog/index', + ], + message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', + }, + { + group: [ + '**/base/toast', + '**/base/toast/index', + '**/base/toast/context', + '**/base/toast/context/index', + ], + message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.', + }, +] + export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx', 'app/components/base/chat/chat-with-history/header/operation.tsx', @@ -27,3 +120,35 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/theme-selector.tsx', 'app/components/base/tooltip/index.tsx', ] + +export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = { + prefix: 'i-', + propMappings: { + size: 'size', + width: 'w', + height: 'h', + }, + libraries: [ + { + prefix: 'i-custom-', + source: '^@/app/components/base/icons/src/(?(?:public|vender)(?:/.*)?)$', + name: '^(?.*)$', + }, + { + source: '^@remixicon/react$', + name: '^(?Ri)(?.+)$', + }, + { + source: '^@(?heroicons)/react/24/outline$', + name: '^(?.*)Icon$', + }, + { + source: '^@(?heroicons)/react/24/(?solid)$', + name: '^(?.*)Icon$', + }, + { + source: '^@(?heroicons)/react/(?\\d+/(?:solid|outline))$', + name: '^(?.*)Icon$', + }, + ], +} diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index 454f580b42..903fd74c5b 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -3,7 +3,6 @@ import type { DSLImportResponse, } from '@/models/app' import type { AppIconType } from '@/types/app' -import { useRouter } from 'next/navigation' import { useCallback, useRef, @@ -15,6 +14,7 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useSelector } from '@/context/app-context' import { DSLImportStatus } from '@/models/app' +import { useRouter } from '@/next/navigation' import { importDSL, importDSLConfirm, diff --git a/web/hooks/use-pay.tsx b/web/hooks/use-pay.tsx index a72107daeb..5ce50fdb0f 100644 --- a/web/hooks/use-pay.tsx +++ b/web/hooks/use-pay.tsx @@ -1,10 +1,10 @@ 'use client' import type { IConfirm } from '@/app/components/base/confirm' -import { useRouter, useSearchParams } from 'next/navigation' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Confirm from '@/app/components/base/confirm' +import { useRouter, useSearchParams } from '@/next/navigation' import { useNotionBinding } from '@/service/use-common' export type ConfirmType = Pick diff --git a/web/i18n/ar-TN/app.json b/web/i18n/ar-TN/app.json index b683e5ad18..93fefd9f1b 100644 --- a/web/i18n/ar-TN/app.json +++ b/web/i18n/ar-TN/app.json @@ -36,6 +36,8 @@ "createApp": "إنشاء تطبيق", "createFromConfigFile": "إنشاء من ملف DSL", "deleteAppConfirmContent": "حذف التطبيق لا رجعة فيه. لن يتمكن المستخدمون من الوصول إلى تطبيقك بعد الآن، وسيتم حذف جميع تكوينات المطالبة والسجلات بشكل دائم.", + "deleteAppConfirmInputLabel": "للتأكيد، اكتب \"{{appName}}\" في المربع أدناه:", + "deleteAppConfirmInputPlaceholder": "أدخل اسم التطبيق", "deleteAppConfirmTitle": "حذف هذا التطبيق؟", "dslUploader.browse": "تصفح", "dslUploader.button": "اسحب وأفلت الملف، أو", diff --git a/web/i18n/de-DE/app.json b/web/i18n/de-DE/app.json index 1162c5f5ca..8af6239c47 100644 --- a/web/i18n/de-DE/app.json +++ b/web/i18n/de-DE/app.json @@ -36,6 +36,8 @@ "createApp": "Neue App erstellen", "createFromConfigFile": "App aus Konfigurationsdatei erstellen", "deleteAppConfirmContent": "Das Löschen der App ist unwiderruflich. Nutzer werden keinen Zugang mehr zu Ihrer App haben, und alle Prompt-Konfigurationen und Logs werden dauerhaft gelöscht.", + "deleteAppConfirmInputLabel": "Geben Sie zur Bestätigung \"{{appName}}\" in das Feld unten ein:", + "deleteAppConfirmInputPlaceholder": "App-Namen eingeben", "deleteAppConfirmTitle": "Diese App löschen?", "dslUploader.browse": "Durchsuchen", "dslUploader.button": "Datei per Drag & Drop ablegen oder", diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index e4109db4b6..f399c5961d 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -36,6 +36,8 @@ "createApp": "CREATE APP", "createFromConfigFile": "Create from DSL file", "deleteAppConfirmContent": "Deleting the app is irreversible. Users will no longer be able to access your app, and all prompt configurations and logs will be permanently deleted.", + "deleteAppConfirmInputLabel": "To confirm, type \"{{appName}}\" in the box below:", + "deleteAppConfirmInputPlaceholder": "Enter app name", "deleteAppConfirmTitle": "Delete this app?", "dslUploader.browse": "Browse", "dslUploader.button": "Drag and drop file, or", diff --git a/web/i18n/en-US/login.json b/web/i18n/en-US/login.json index 8a3bf04ac9..ec474aa4fb 100644 --- a/web/i18n/en-US/login.json +++ b/web/i18n/en-US/login.json @@ -35,6 +35,8 @@ "error.emailEmpty": "Email address is required", "error.emailInValid": "Please enter a valid email address", "error.invalidEmailOrPassword": "Invalid email or password.", + "error.invalidRedirectUrlOrAppCode": "Invalid redirect URL or app code", + "error.invalidSSOProtocol": "Invalid SSO protocol", "error.nameEmpty": "Name is required", "error.passwordEmpty": "Password is required", "error.passwordInvalid": "Password must contain letters and numbers, and the length must be greater than 8", diff --git a/web/i18n/es-ES/app.json b/web/i18n/es-ES/app.json index 24c743e671..5dece4801f 100644 --- a/web/i18n/es-ES/app.json +++ b/web/i18n/es-ES/app.json @@ -36,6 +36,8 @@ "createApp": "CREAR APP", "createFromConfigFile": "Crear desde archivo DSL", "deleteAppConfirmContent": "Eliminar la app es irreversible. Los usuarios ya no podrán acceder a tu app y todas las configuraciones y registros de prompts se eliminarán permanentemente.", + "deleteAppConfirmInputLabel": "Para confirmar, escriba \"{{appName}}\" en el cuadro a continuación:", + "deleteAppConfirmInputPlaceholder": "Ingrese el nombre de la app", "deleteAppConfirmTitle": "¿Eliminar esta app?", "dslUploader.browse": "Examinar", "dslUploader.button": "Arrastrar y soltar archivo, o", diff --git a/web/i18n/fa-IR/app.json b/web/i18n/fa-IR/app.json index 0c011d18ca..a07a08bda8 100644 --- a/web/i18n/fa-IR/app.json +++ b/web/i18n/fa-IR/app.json @@ -36,6 +36,8 @@ "createApp": "ایجاد برنامه", "createFromConfigFile": "ایجاد از فایل DSL", "deleteAppConfirmContent": "حذف برنامه غیرقابل برگشت است. کاربران دیگر قادر به دسترسی به برنامه شما نخواهند بود و تمام تنظیمات و گزارشات درخواست‌ها به صورت دائم حذف خواهند شد.", + "deleteAppConfirmInputLabel": "برای تأیید، \"{{appName}}\" را در کادر زیر تایپ کنید:", + "deleteAppConfirmInputPlaceholder": "نام برنامه را وارد کنید", "deleteAppConfirmTitle": "آیا این برنامه حذف شود؟", "dslUploader.browse": "مرور", "dslUploader.button": "فایل را بکشید و رها کنید، یا", diff --git a/web/i18n/fr-FR/app.json b/web/i18n/fr-FR/app.json index a5defb7783..056aa5be0a 100644 --- a/web/i18n/fr-FR/app.json +++ b/web/i18n/fr-FR/app.json @@ -36,6 +36,8 @@ "createApp": "CRÉER UNE APPLICATION", "createFromConfigFile": "Créer à partir du fichier DSL", "deleteAppConfirmContent": "La suppression de l'application est irréversible. Les utilisateurs ne pourront plus accéder à votre application et toutes les configurations de prompt et les journaux seront définitivement supprimés.", + "deleteAppConfirmInputLabel": "Pour confirmer, tapez \"{{appName}}\" dans la case ci-dessous :", + "deleteAppConfirmInputPlaceholder": "Entrez le nom de l'application", "deleteAppConfirmTitle": "Supprimer cette application ?", "dslUploader.browse": "Parcourir", "dslUploader.button": "Glisser-déposer un fichier, ou", diff --git a/web/i18n/hi-IN/app.json b/web/i18n/hi-IN/app.json index a67961d6d1..a6b3bbe446 100644 --- a/web/i18n/hi-IN/app.json +++ b/web/i18n/hi-IN/app.json @@ -36,6 +36,8 @@ "createApp": "ऐप बनाएँ", "createFromConfigFile": "डीएसएल फ़ाइल से बनाएँ", "deleteAppConfirmContent": "ऐप को हटाना अपरिवर्तनीय है। उपयोगकर्ता अब आपके ऐप तक पहुँचने में सक्षम नहीं होंगे, और सभी प्रॉम्प्ट कॉन्फ़िगरेशन और लॉग स्थायी रूप से हटा दिए जाएंगे।", + "deleteAppConfirmInputLabel": "पुष्टि करने के लिए, नीचे दिए गए बॉक्स में \"{{appName}}\" टाइप करें:", + "deleteAppConfirmInputPlaceholder": "ऐप का नाम दर्ज करें", "deleteAppConfirmTitle": "इस ऐप को हटाएँ?", "dslUploader.browse": "ब्राउज़ करें", "dslUploader.button": "फ़ाइल खींचकर छोड़ें, या", diff --git a/web/i18n/id-ID/app.json b/web/i18n/id-ID/app.json index e85647c7ca..d6249cb2d2 100644 --- a/web/i18n/id-ID/app.json +++ b/web/i18n/id-ID/app.json @@ -36,6 +36,8 @@ "createApp": "BUAT APLIKASI", "createFromConfigFile": "Buat dari file DSL", "deleteAppConfirmContent": "Menghapus aplikasi tidak dapat diubah. Pengguna tidak akan dapat lagi mengakses aplikasi Anda, dan semua konfigurasi prompt serta log akan dihapus secara permanen.", + "deleteAppConfirmInputLabel": "Untuk konfirmasi, ketik \"{{appName}}\" di kotak di bawah ini:", + "deleteAppConfirmInputPlaceholder": "Masukkan nama aplikasi", "deleteAppConfirmTitle": "Hapus aplikasi ini?", "dslUploader.browse": "Ramban", "dslUploader.button": "Seret dan lepas file, atau", diff --git a/web/i18n/it-IT/app.json b/web/i18n/it-IT/app.json index 7020e35d7b..0364768909 100644 --- a/web/i18n/it-IT/app.json +++ b/web/i18n/it-IT/app.json @@ -36,6 +36,8 @@ "createApp": "CREA APP", "createFromConfigFile": "Crea da file DSL", "deleteAppConfirmContent": "Eliminare l'app è irreversibile. Gli utenti non potranno più accedere alla tua app e tutte le configurazioni e i log dei prompt verranno eliminati permanentemente.", + "deleteAppConfirmInputLabel": "Per confermare, digita \"{{appName}}\" nel campo sottostante:", + "deleteAppConfirmInputPlaceholder": "Inserisci il nome dell'app", "deleteAppConfirmTitle": "Eliminare questa app?", "dslUploader.browse": "Sfoglia", "dslUploader.button": "Trascina e rilascia il file, o", diff --git a/web/i18n/ja-JP/app.json b/web/i18n/ja-JP/app.json index f48e61f2fc..ca34df1b3f 100644 --- a/web/i18n/ja-JP/app.json +++ b/web/i18n/ja-JP/app.json @@ -36,6 +36,8 @@ "createApp": "アプリを作成する", "createFromConfigFile": "DSL ファイルから作成する", "deleteAppConfirmContent": "アプリを削除すると、元に戻すことはできません。他のユーザーはもはやこのアプリにアクセスできず、すべてのプロンプトの設定とログが永久に削除されます。", + "deleteAppConfirmInputLabel": "確認するには、下のボックスに「{{appName}}」と入力してください:", + "deleteAppConfirmInputPlaceholder": "アプリ名を入力", "deleteAppConfirmTitle": "このアプリを削除しますか?", "dslUploader.browse": "参照", "dslUploader.button": "ファイルをドラッグ&ドロップするか、", diff --git a/web/i18n/ko-KR/app.json b/web/i18n/ko-KR/app.json index 31a18af292..a13699442b 100644 --- a/web/i18n/ko-KR/app.json +++ b/web/i18n/ko-KR/app.json @@ -36,6 +36,8 @@ "createApp": "앱 만들기", "createFromConfigFile": "DSL 파일에서 생성하기", "deleteAppConfirmContent": "앱을 삭제하면 복구할 수 없습니다. 사용자는 더 이상 앱에 액세스할 수 없으며 모든 프롬프트 설정 및 로그가 영구적으로 삭제됩니다.", + "deleteAppConfirmInputLabel": "확인하려면 아래 상자에 \"{{appName}}\"을 입력하세요:", + "deleteAppConfirmInputPlaceholder": "앱 이름 입력", "deleteAppConfirmTitle": "이 앱을 삭제하시겠습니까?", "dslUploader.browse": "찾아보기", "dslUploader.button": "파일을 드래그 앤 드롭하거나", diff --git a/web/i18n/nl-NL/app.json b/web/i18n/nl-NL/app.json index e4109db4b6..f399c5961d 100644 --- a/web/i18n/nl-NL/app.json +++ b/web/i18n/nl-NL/app.json @@ -36,6 +36,8 @@ "createApp": "CREATE APP", "createFromConfigFile": "Create from DSL file", "deleteAppConfirmContent": "Deleting the app is irreversible. Users will no longer be able to access your app, and all prompt configurations and logs will be permanently deleted.", + "deleteAppConfirmInputLabel": "To confirm, type \"{{appName}}\" in the box below:", + "deleteAppConfirmInputPlaceholder": "Enter app name", "deleteAppConfirmTitle": "Delete this app?", "dslUploader.browse": "Browse", "dslUploader.button": "Drag and drop file, or", diff --git a/web/i18n/pl-PL/app.json b/web/i18n/pl-PL/app.json index a4d851a5c7..9539db9a58 100644 --- a/web/i18n/pl-PL/app.json +++ b/web/i18n/pl-PL/app.json @@ -36,6 +36,8 @@ "createApp": "UTWÓRZ APLIKACJĘ", "createFromConfigFile": "Utwórz z pliku DSL", "deleteAppConfirmContent": "Usunięcie aplikacji jest nieodwracalne. Użytkownicy nie będą mieli już dostępu do twojej aplikacji, a wszystkie konfiguracje monitów i dzienniki zostaną trwale usunięte.", + "deleteAppConfirmInputLabel": "Aby potwierdzić, wpisz \"{{appName}}\" w polu poniżej:", + "deleteAppConfirmInputPlaceholder": "Wpisz nazwę aplikacji", "deleteAppConfirmTitle": "Usunąć tę aplikację?", "dslUploader.browse": "Przeglądaj", "dslUploader.button": "Przeciągnij i upuść plik, lub", diff --git a/web/i18n/pt-BR/app.json b/web/i18n/pt-BR/app.json index e97c923c39..9d6fd0b52c 100644 --- a/web/i18n/pt-BR/app.json +++ b/web/i18n/pt-BR/app.json @@ -36,6 +36,8 @@ "createApp": "CRIAR APLICATIVO", "createFromConfigFile": "Criar a partir do arquivo DSL", "deleteAppConfirmContent": "A exclusão do aplicativo é irreversível. Os usuários não poderão mais acessar seu aplicativo e todas as configurações de prompt e logs serão permanentemente excluídas.", + "deleteAppConfirmInputLabel": "Para confirmar, digite \"{{appName}}\" na caixa abaixo:", + "deleteAppConfirmInputPlaceholder": "Digite o nome do aplicativo", "deleteAppConfirmTitle": "Excluir este aplicativo?", "dslUploader.browse": "Navegar", "dslUploader.button": "Arraste e solte o arquivo, ou", diff --git a/web/i18n/ro-RO/app.json b/web/i18n/ro-RO/app.json index 2e4eb2e72d..de0ecf5f63 100644 --- a/web/i18n/ro-RO/app.json +++ b/web/i18n/ro-RO/app.json @@ -36,6 +36,8 @@ "createApp": "CREEAZĂ APLICAȚIE", "createFromConfigFile": "Creează din fișier DSL", "deleteAppConfirmContent": "Ștergerea aplicației este ireversibilă. Utilizatorii nu vor mai putea accesa aplicația ta, iar toate configurațiile promptului și jurnalele vor fi șterse permanent.", + "deleteAppConfirmInputLabel": "Pentru confirmare, tastați \"{{appName}}\" în caseta de mai jos:", + "deleteAppConfirmInputPlaceholder": "Introduceți numele aplicației", "deleteAppConfirmTitle": "Ștergi această aplicație?", "dslUploader.browse": "Răsfoiți", "dslUploader.button": "Trageți și plasați fișierul, sau", diff --git a/web/i18n/ru-RU/app.json b/web/i18n/ru-RU/app.json index fbacd43c0e..8f275934c2 100644 --- a/web/i18n/ru-RU/app.json +++ b/web/i18n/ru-RU/app.json @@ -36,6 +36,8 @@ "createApp": "СОЗДАТЬ ПРИЛОЖЕНИЕ", "createFromConfigFile": "Создать из файла DSL", "deleteAppConfirmContent": "Удаление приложения необратимо. Пользователи больше не смогут получить доступ к вашему приложению, и все настройки подсказок и журналы будут безвозвратно удалены.", + "deleteAppConfirmInputLabel": "Для подтверждения введите \"{{appName}}\" в поле ниже:", + "deleteAppConfirmInputPlaceholder": "Введите название приложения", "deleteAppConfirmTitle": "Удалить это приложение?", "dslUploader.browse": "Обзор", "dslUploader.button": "Перетащите файл, или", diff --git a/web/i18n/sl-SI/app.json b/web/i18n/sl-SI/app.json index eb56c39a2f..c4f9c02bda 100644 --- a/web/i18n/sl-SI/app.json +++ b/web/i18n/sl-SI/app.json @@ -36,6 +36,8 @@ "createApp": "USTVARI APLIKACIJO", "createFromConfigFile": "Ustvari iz datoteke DSL", "deleteAppConfirmContent": "Brisanje aplikacije je nepopravljivo. Uporabniki ne bodo več imeli dostopa do vaše aplikacije, vse konfiguracije in dnevniki pa bodo trajno izbrisani.", + "deleteAppConfirmInputLabel": "Za potrditev vnesite \"{{appName}}\" v polje spodaj:", + "deleteAppConfirmInputPlaceholder": "Vnesite ime aplikacije", "deleteAppConfirmTitle": "Izbrišem to aplikacijo?", "dslUploader.browse": "Prebrskaj", "dslUploader.button": "Povlecite in spustite datoteko, ali", diff --git a/web/i18n/th-TH/app.json b/web/i18n/th-TH/app.json index ba6f815e78..aa3c67a178 100644 --- a/web/i18n/th-TH/app.json +++ b/web/i18n/th-TH/app.json @@ -36,6 +36,8 @@ "createApp": "สร้างโปรเจกต์ใหม่", "createFromConfigFile": "สร้างจากไฟล์ DSL", "deleteAppConfirmContent": "การลบโปรเจกนั้นไม่สามารถย้อนกลับได้ ผู้ใช้จะไม่สามารถเข้าถึงโปรเจกต์ของคุณอีกต่อไป และการกําหนดค่าต่างๆและบันทึกทั้งหมดจะถูกลบอย่างถาวร", + "deleteAppConfirmInputLabel": "หากต้องการยืนยัน พิมพ์ \"{{appName}}\" ในช่องด้านล่าง:", + "deleteAppConfirmInputPlaceholder": "ใส่ชื่อแอป", "deleteAppConfirmTitle": "ลบโปรเจกต์นี้?", "dslUploader.browse": "เรียกดู", "dslUploader.button": "ลากและวางไฟล์ หรือ", diff --git a/web/i18n/tr-TR/app.json b/web/i18n/tr-TR/app.json index 4db749c51a..af6c5bdcd9 100644 --- a/web/i18n/tr-TR/app.json +++ b/web/i18n/tr-TR/app.json @@ -36,6 +36,8 @@ "createApp": "UYGULAMA OLUŞTUR", "createFromConfigFile": "DSL dosyasından oluştur", "deleteAppConfirmContent": "Uygulamanın silinmesi geri alınamaz. Kullanıcılar artık uygulamanıza erişemeyecek ve tüm prompt yapılandırmaları ile loglar kalıcı olarak silinecektir.", + "deleteAppConfirmInputLabel": "Onaylamak için aşağıdaki kutuya \"{{appName}}\" yazın:", + "deleteAppConfirmInputPlaceholder": "Uygulama adını girin", "deleteAppConfirmTitle": "Bu uygulamayı silmek istiyor musunuz?", "dslUploader.browse": "Gözat", "dslUploader.button": "Dosyayı sürükleyip bırakın veya", diff --git a/web/i18n/uk-UA/app.json b/web/i18n/uk-UA/app.json index 863a5b903b..9633000fea 100644 --- a/web/i18n/uk-UA/app.json +++ b/web/i18n/uk-UA/app.json @@ -36,6 +36,8 @@ "createApp": "Створити додаток", "createFromConfigFile": "Створити з файлу DSL", "deleteAppConfirmContent": "Видалення додатка незворотнє. Користувачі більше не зможуть отримати доступ до вашого додатка, і всі налаштування запитів та журнали будуть остаточно видалені.", + "deleteAppConfirmInputLabel": "Для підтвердження введіть \"{{appName}}\" у поле нижче:", + "deleteAppConfirmInputPlaceholder": "Введіть назву додатка", "deleteAppConfirmTitle": "Видалити цей додаток?", "dslUploader.browse": "Огляд", "dslUploader.button": "Перетягніть файл, або", diff --git a/web/i18n/vi-VN/app.json b/web/i18n/vi-VN/app.json index 1e6821240d..527b69e79d 100644 --- a/web/i18n/vi-VN/app.json +++ b/web/i18n/vi-VN/app.json @@ -36,6 +36,8 @@ "createApp": "TẠO ỨNG DỤNG", "createFromConfigFile": "Tạo từ tệp DSL", "deleteAppConfirmContent": "Việc xóa ứng dụng là không thể hoàn tác. Người dùng sẽ không thể truy cập vào ứng dụng của bạn nữa và tất cả cấu hình cũng như nhật ký nhắc sẽ bị xóa vĩnh viễn.", + "deleteAppConfirmInputLabel": "Để xác nhận, hãy nhập \"{{appName}}\" vào ô bên dưới:", + "deleteAppConfirmInputPlaceholder": "Nhập tên ứng dụng", "deleteAppConfirmTitle": "Xóa ứng dụng này?", "dslUploader.browse": "Duyệt", "dslUploader.button": "Kéo và thả tệp, hoặc", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index ee60cd3413..92c5f15c79 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -36,6 +36,8 @@ "createApp": "创建应用", "createFromConfigFile": "通过 DSL 文件创建", "deleteAppConfirmContent": "删除应用将无法撤销。用户将不能访问你的应用,所有 Prompt 编排配置和日志均将一并被删除。", + "deleteAppConfirmInputLabel": "请在下方输入框中输入\"{{appName}}\"以确认:", + "deleteAppConfirmInputPlaceholder": "输入应用名称", "deleteAppConfirmTitle": "确认删除应用?", "dslUploader.browse": "选择文件", "dslUploader.button": "拖拽文件至此,或者", diff --git a/web/i18n/zh-Hant/app.json b/web/i18n/zh-Hant/app.json index 1c739320f6..0b7e9691a9 100644 --- a/web/i18n/zh-Hant/app.json +++ b/web/i18n/zh-Hant/app.json @@ -36,6 +36,8 @@ "createApp": "建立應用", "createFromConfigFile": "透過 DSL 檔案建立", "deleteAppConfirmContent": "刪除應用將無法復原。使用者將無法存取你的應用,所有 Prompt 設定和日誌都將一併被刪除。", + "deleteAppConfirmInputLabel": "請在下方輸入框中輸入「{{appName}}」以確認:", + "deleteAppConfirmInputPlaceholder": "輸入應用程式名稱", "deleteAppConfirmTitle": "確認刪除應用?", "dslUploader.browse": "選擇檔案", "dslUploader.button": "拖拽檔案至此,或者", diff --git a/web/next/link.ts b/web/next/link.ts new file mode 100644 index 0000000000..c99bd22206 --- /dev/null +++ b/web/next/link.ts @@ -0,0 +1 @@ +export { default } from 'next/link' diff --git a/web/next/navigation.ts b/web/next/navigation.ts new file mode 100644 index 0000000000..ec7c112645 --- /dev/null +++ b/web/next/navigation.ts @@ -0,0 +1,8 @@ +export { + useParams, + usePathname, + useRouter, + useSearchParams, + useSelectedLayoutSegment, + useSelectedLayoutSegments, +} from 'next/navigation' diff --git a/web/package.json b/web/package.json index 4d9b5b9be7..5c08965ed0 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "dify-web", "type": "module", - "version": "1.13.1", + "version": "1.13.2", "private": true, "packageManager": "pnpm@10.32.1", "imports": { @@ -77,10 +77,10 @@ "@monaco-editor/react": "4.7.0", "@octokit/core": "7.0.6", "@octokit/request-error": "7.1.0", - "@orpc/client": "1.13.7", - "@orpc/contract": "1.13.7", - "@orpc/openapi-client": "1.13.7", - "@orpc/tanstack-query": "1.13.7", + "@orpc/client": "1.13.8", + "@orpc/contract": "1.13.8", + "@orpc/openapi-client": "1.13.8", + "@orpc/tanstack-query": "1.13.8", "@remixicon/react": "4.9.0", "@sentry/react": "10.44.0", "@streamdown/math": "1.0.2", @@ -88,7 +88,7 @@ "@t3-oss/env-nextjs": "0.13.10", "@tailwindcss/typography": "0.5.19", "@tanstack/react-form": "1.28.5", - "@tanstack/react-query": "5.90.21", + "@tanstack/react-query": "5.91.0", "abcjs": "6.6.2", "ahooks": "3.9.6", "class-variance-authority": "0.7.1", @@ -127,7 +127,7 @@ "mime": "4.1.0", "mitt": "3.0.1", "negotiator": "1.0.0", - "next": "16.1.7", + "next": "16.2.0", "next-themes": "0.4.6", "nuqs": "2.8.9", "pinyin-pro": "3.28.0", @@ -151,9 +151,9 @@ "remark-breaks": "4.0.0", "remark-directive": "4.0.0", "scheduler": "0.27.0", - "semver": "7.7.4", "sharp": "0.34.5", "sortablejs": "1.15.7", + "std-semver": "1.0.8", "streamdown": "2.5.0", "string-ts": "2.3.1", "tailwind-merge": "2.6.1", @@ -175,16 +175,16 @@ "@mdx-js/loader": "3.1.1", "@mdx-js/react": "3.1.1", "@mdx-js/rollup": "3.1.1", - "@next/eslint-plugin-next": "16.1.7", - "@next/mdx": "16.1.7", + "@next/eslint-plugin-next": "16.2.0", + "@next/mdx": "16.2.0", "@rgrove/parse-xml": "4.2.0", - "@storybook/addon-docs": "10.2.19", - "@storybook/addon-links": "10.2.19", - "@storybook/addon-onboarding": "10.2.19", - "@storybook/addon-themes": "10.2.19", - "@storybook/nextjs-vite": "10.2.19", - "@storybook/react": "10.2.19", - "@tanstack/eslint-plugin-query": "5.91.4", + "@storybook/addon-docs": "10.3.0", + "@storybook/addon-links": "10.3.0", + "@storybook/addon-onboarding": "10.3.0", + "@storybook/addon-themes": "10.3.0", + "@storybook/nextjs-vite": "10.3.0", + "@storybook/react": "10.3.0", + "@tanstack/eslint-plugin-query": "5.91.5", "@tanstack/react-devtools": "0.10.0", "@tanstack/react-form-devtools": "0.2.19", "@tanstack/react-query-devtools": "5.91.3", @@ -206,10 +206,9 @@ "@types/react-slider": "1.3.6", "@types/react-syntax-highlighter": "15.5.13", "@types/react-window": "1.8.8", - "@types/semver": "7.7.1", "@types/sortablejs": "1.15.9", "@typescript-eslint/parser": "8.57.1", - "@typescript/native-preview": "7.0.0-dev.20260317.1", + "@typescript/native-preview": "7.0.0-dev.20260318.1", "@vitejs/plugin-react": "6.0.1", "@vitejs/plugin-rsc": "0.5.21", "@vitest/coverage-v8": "4.1.0", @@ -222,19 +221,19 @@ "eslint-plugin-react-hooks": "7.0.1", "eslint-plugin-react-refresh": "0.5.2", "eslint-plugin-sonarjs": "4.0.2", - "eslint-plugin-storybook": "10.2.19", + "eslint-plugin-storybook": "10.3.0", "husky": "9.1.7", "iconify-import-svg": "0.1.2", "jsdom": "29.0.0", "jsdom-testing-mocks": "1.16.0", - "knip": "5.87.0", + "knip": "5.88.0", "lint-staged": "16.4.0", "nock": "14.0.11", "postcss": "8.5.8", "postcss-js": "5.1.0", "react-server-dom-webpack": "19.2.4", "sass": "1.98.0", - "storybook": "10.2.19", + "storybook": "10.3.0", "tailwindcss": "3.4.19", "taze": "19.10.0", "tsx": "4.21.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b8a67542a3..5c4ccfc5c8 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -119,17 +119,17 @@ importers: specifier: 7.1.0 version: 7.1.0 '@orpc/client': - specifier: 1.13.7 - version: 1.13.7 + specifier: 1.13.8 + version: 1.13.8 '@orpc/contract': - specifier: 1.13.7 - version: 1.13.7 + specifier: 1.13.8 + version: 1.13.8 '@orpc/openapi-client': - specifier: 1.13.7 - version: 1.13.7 + specifier: 1.13.8 + version: 1.13.8 '@orpc/tanstack-query': - specifier: 1.13.7 - version: 1.13.7(@orpc/client@1.13.7)(@tanstack/query-core@5.90.20) + specifier: 1.13.8 + version: 1.13.8(@orpc/client@1.13.8)(@tanstack/query-core@5.91.0) '@remixicon/react': specifier: 4.9.0 version: 4.9.0(react@19.2.4) @@ -152,8 +152,8 @@ importers: specifier: 1.28.5 version: 1.28.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': - specifier: 5.90.21 - version: 5.90.21(react@19.2.4) + specifier: 5.91.0 + version: 5.91.0(react@19.2.4) abcjs: specifier: 6.6.2 version: 6.6.2 @@ -269,14 +269,14 @@ importers: specifier: 1.0.0 version: 1.0.0 next: - specifier: 16.1.7 - version: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + specifier: 16.2.0 + version: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) next-themes: specifier: 0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) nuqs: specifier: 2.8.9 - version: 2.8.9(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4) + version: 2.8.9(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4) pinyin-pro: specifier: 3.28.0 version: 3.28.0 @@ -340,15 +340,15 @@ importers: scheduler: specifier: 0.27.0 version: 0.27.0 - semver: - specifier: 7.7.4 - version: 7.7.4 sharp: specifier: 0.34.5 version: 0.34.5 sortablejs: specifier: 1.15.7 version: 1.15.7 + std-semver: + specifier: 1.0.8 + version: 1.0.8 streamdown: specifier: 2.5.0 version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -382,10 +382,10 @@ importers: devDependencies: '@antfu/eslint-config': specifier: 7.7.3 - version: 7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.7)(@typescript-eslint/rule-tester@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.12(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(jiti@1.21.7)(jsdom@29.0.0(canvas@3.2.1))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.3(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.3(jiti@1.21.7)))(eslint@10.0.3(jiti@1.21.7))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(typescript@5.9.3) + version: 7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.2.0)(@typescript-eslint/rule-tester@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.12(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(jiti@1.21.7)(jsdom@29.0.0(canvas@3.2.1))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.3(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.3(jiti@1.21.7)))(eslint@10.0.3(jiti@1.21.7))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(typescript@5.9.3) '@chromatic-com/storybook': specifier: 5.0.1 - version: 5.0.1(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 5.0.1(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@egoist/tailwindcss-icons': specifier: 1.9.2 version: 1.9.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) @@ -408,35 +408,35 @@ importers: specifier: 3.1.1 version: 3.1.1(rollup@4.59.0) '@next/eslint-plugin-next': - specifier: 16.1.7 - version: 16.1.7 + specifier: 16.2.0 + version: 16.2.0 '@next/mdx': - specifier: 16.1.7 - version: 16.1.7(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)) + specifier: 16.2.0 + version: 16.2.0(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)) '@rgrove/parse-xml': specifier: 4.2.0 version: 4.2.0 '@storybook/addon-docs': - specifier: 10.2.19 - version: 10.2.19(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + specifier: 10.3.0 + version: 10.3.0(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/addon-links': - specifier: 10.2.19 - version: 10.2.19(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + specifier: 10.3.0 + version: 10.3.0(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-onboarding': - specifier: 10.2.19 - version: 10.2.19(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + specifier: 10.3.0 + version: 10.3.0(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/addon-themes': - specifier: 10.2.19 - version: 10.2.19(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + specifier: 10.3.0 + version: 10.3.0(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/nextjs-vite': - specifier: 10.2.19 - version: 10.2.19(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + specifier: 10.3.0 + version: 10.3.0(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/react': - specifier: 10.2.19 - version: 10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + specifier: 10.3.0 + version: 10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) '@tanstack/eslint-plugin-query': - specifier: 5.91.4 - version: 5.91.4(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3) + specifier: 5.91.5 + version: 5.91.5(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3) '@tanstack/react-devtools': specifier: 0.10.0 version: 0.10.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11) @@ -445,7 +445,7 @@ importers: version: 0.2.19(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.4)(solid-js@1.9.11) '@tanstack/react-query-devtools': specifier: 5.91.3 - version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) + version: 5.91.3(@tanstack/react-query@5.91.0(react@19.2.4))(react@19.2.4) '@testing-library/dom': specifier: 10.4.1 version: 10.4.1 @@ -500,9 +500,6 @@ importers: '@types/react-window': specifier: 1.8.8 version: 1.8.8 - '@types/semver': - specifier: 7.7.1 - version: 7.7.1 '@types/sortablejs': specifier: 1.15.9 version: 1.15.9 @@ -510,8 +507,8 @@ importers: specifier: 8.57.1 version: 8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3) '@typescript/native-preview': - specifier: 7.0.0-dev.20260317.1 - version: 7.0.0-dev.20260317.1 + specifier: 7.0.0-dev.20260318.1 + version: 7.0.0-dev.20260318.1 '@vitejs/plugin-react': specifier: 6.0.1 version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) @@ -549,8 +546,8 @@ importers: specifier: 4.0.2 version: 4.0.2(eslint@10.0.3(jiti@1.21.7)) eslint-plugin-storybook: - specifier: 10.2.19 - version: 10.2.19(eslint@10.0.3(jiti@1.21.7))(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + specifier: 10.3.0 + version: 10.3.0(eslint@10.0.3(jiti@1.21.7))(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) husky: specifier: 9.1.7 version: 9.1.7 @@ -564,8 +561,8 @@ importers: specifier: 1.16.0 version: 1.16.0 knip: - specifier: 5.87.0 - version: 5.87.0(@types/node@25.5.0)(typescript@5.9.3) + specifier: 5.88.0 + version: 5.88.0(@types/node@25.5.0)(typescript@5.9.3) lint-staged: specifier: 16.4.0 version: 16.4.0 @@ -585,8 +582,8 @@ importers: specifier: 1.98.0 version: 1.98.0 storybook: - specifier: 10.2.19 - version: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 10.3.0 + version: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwindcss: specifier: 3.4.19 version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) @@ -604,7 +601,7 @@ importers: version: 3.19.3 vinext: specifier: 0.0.31 - version: 0.0.31(cde6d75387d76fb961ef48e4fe505e24) + version: 0.0.31(d43efe4756ad5ea698dcdb002ea787ea) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.12 version: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)' @@ -1699,14 +1696,14 @@ packages: '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} - '@next/env@16.1.7': - resolution: {integrity: sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==} + '@next/env@16.2.0': + resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==} - '@next/eslint-plugin-next@16.1.7': - resolution: {integrity: sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==} + '@next/eslint-plugin-next@16.2.0': + resolution: {integrity: sha512-3D3pEMcGKfENC9Pzlkr67GOm+205+5hRdYPZvHuNIy5sr9k0ybSU8g+sxOO/R/RLEh/gWZ3UlY+5LmEyZ1xgXQ==} - '@next/mdx@16.1.7': - resolution: {integrity: sha512-19KG2bg7oDXoz7Jy9K2mMsq41VYcGlcHmi/iz4YgYcOJZiRIsLWJxVjySm4wFwOTpvQOqyALqm02OXzHGjBwWA==} + '@next/mdx@16.2.0': + resolution: {integrity: sha512-I+qgh34a9tNfZpz0TdMT8c6CjUEjatFx7njvQXKi3gbQtuRc5MyHYyyP7+GBtOpmtSUocnI+I+SaVQK/8UFIIw==} peerDependencies: '@mdx-js/loader': '>=0.15.0' '@mdx-js/react': '>=0.15.0' @@ -1716,54 +1713,54 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@16.1.7': - resolution: {integrity: sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==} + '@next/swc-darwin-arm64@16.2.0': + resolution: {integrity: sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.1.7': - resolution: {integrity: sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==} + '@next/swc-darwin-x64@16.2.0': + resolution: {integrity: sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.1.7': - resolution: {integrity: sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==} + '@next/swc-linux-arm64-gnu@16.2.0': + resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@16.1.7': - resolution: {integrity: sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==} + '@next/swc-linux-arm64-musl@16.2.0': + resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@16.1.7': - resolution: {integrity: sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==} + '@next/swc-linux-x64-gnu@16.2.0': + resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@16.1.7': - resolution: {integrity: sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==} + '@next/swc-linux-x64-musl@16.2.0': + resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@16.1.7': - resolution: {integrity: sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==} + '@next/swc-win32-arm64-msvc@16.2.0': + resolution: {integrity: sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.1.7': - resolution: {integrity: sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==} + '@next/swc-win32-x64-msvc@16.2.0': + resolution: {integrity: sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1831,36 +1828,36 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@orpc/client@1.13.7': - resolution: {integrity: sha512-qqmS28q0GOo9sfTstAc6cy0NoitYvdwZlgGBLXDgXsHNtSkkSm3nI5M1ohgWYpSL+lfP/jWriTbJJrBYPzyCiQ==} + '@orpc/client@1.13.8': + resolution: {integrity: sha512-7B8NDjBjP17Mrrgc/YeZl9b0YBu2Sk9/lKyVeG3755tyrAPLiezWuwQEaP9T45S2/+g8LTzFmV2R504Wn5R5MQ==} - '@orpc/contract@1.13.7': - resolution: {integrity: sha512-zRm+5tvn8DVM4DHJVxQEiuoM7mGdlgIQzRjTqnJMBRV0+rBNyZniokZCGwfJHKCjtBaAtJWieuJLQ+dFj3gbcw==} + '@orpc/contract@1.13.8': + resolution: {integrity: sha512-W8hjVYDnsHI63TgQUGB4bb+ldCqR5hdxL1o2b7ytkFEkXTft6HOrHHvv+ncmgK1c1XapD1ScsCj11zzxf5NUGQ==} - '@orpc/openapi-client@1.13.7': - resolution: {integrity: sha512-0oWElGEtZ/EbfwOliMI6PccALpi8tp3aOyU746kJQRKTkSAwGohyRvfRA1R7BLbA3xwHTdNYB6ThaeYLvqiA0g==} + '@orpc/openapi-client@1.13.8': + resolution: {integrity: sha512-Cg7oDhbiO9bPpseRaFeWIhZFoA1bCF2pPxAJZj6/YtHkh+VSDI8W1xzbzoKNp2YHnhhJfgpIuVsHD42tX73+Mw==} - '@orpc/shared@1.13.7': - resolution: {integrity: sha512-yP0oDIC98sZHqSTmr4SUXJo4RNw9yias1GYVJTiVTXrRUEdniafkLrSkOrOHgrILP3w93sKiE69V3+/T0TNSOQ==} + '@orpc/shared@1.13.8': + resolution: {integrity: sha512-d7bZW2F8/ov6JFuGEMeh7XYZtW4+zgjxW5DKBv5tNkWmZEC5JJQz8l6Ym9ZRe2VyRzQgo5JarJGsVQlmqVVvhw==} peerDependencies: '@opentelemetry/api': '>=1.9.0' peerDependenciesMeta: '@opentelemetry/api': optional: true - '@orpc/standard-server-fetch@1.13.7': - resolution: {integrity: sha512-Hj+41HAlao+JXuLffeLrPiADu8mhGqwVB34lf+JSLKGtZhxaX4n4MeZMYhFioExXC+/tanvSrbKKkJimfznIWQ==} + '@orpc/standard-server-fetch@1.13.8': + resolution: {integrity: sha512-g26Loo7GFTCF/S5QsM3Z6Xd9ZYs90K7jtRtEqbJh03YNrjecvZdpUKd/lTf/9kpJTBTQbhFxC9WCAJH4+8leFA==} - '@orpc/standard-server-peer@1.13.7': - resolution: {integrity: sha512-mbjmkEVGtsWvGBBEieUuXdX+MAzllQZ0D9Z79kU4Ns9sVaBcvjCYSrL29/iXcYqVGTx23LS9PaYnIurAQejzSQ==} + '@orpc/standard-server-peer@1.13.8': + resolution: {integrity: sha512-ZyzWT6zZnLJkX15r04ecSDAJmkQ46PXTovORmK7RzOV47qIB7IryiRGR60U4WygBX0VDzZU8cgcXidZTx4v7oA==} - '@orpc/standard-server@1.13.7': - resolution: {integrity: sha512-5btxVTRAtgl9lmzg1XTCJYT8qd2QAAwcQ6XRvGXgLz56rSUCMf2vl3WeWPwlwiXXpueNvucPea/CaRGhJ9ZTeQ==} + '@orpc/standard-server@1.13.8': + resolution: {integrity: sha512-/v72eRSPFzWt6SoHDC04cjZfwdW94z3aib7dMBat32aK3eXwfRZmwPPmfVBQO/ZlJYlq+5rSdPoMKkSoirG/5Q==} - '@orpc/tanstack-query@1.13.7': - resolution: {integrity: sha512-MBxs86GBMjI5DXemTXn9W5jvgYEafdj33RpK5fXIrX+uEfwnJbmiWZCvFg7EOnDBUGLVnCWKLWow+tVspOFVmA==} + '@orpc/tanstack-query@1.13.8': + resolution: {integrity: sha512-ZUwwkAqoGPOCs8gBG7w6vVNxUOAJyTBVUuclmZoyTdbb5xgMVtUGCvyjiwaWOSoL4+N2urZBbvNdTbEMsuoqLQ==} peerDependencies: - '@orpc/client': 1.13.7 + '@orpc/client': 1.13.8 '@tanstack/query-core': '>=5.80.2' '@ota-meshi/ast-token-store@0.3.0': @@ -2873,42 +2870,42 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@storybook/addon-docs@10.2.19': - resolution: {integrity: sha512-tXugthdzjX5AkGWDSP4pnRgA/CWlOaEKp/+y9JOGXHLQmm1GHjW+4brNvNkKbjBl06LALXwlcTOyU4lyVRDLAw==} + '@storybook/addon-docs@10.3.0': + resolution: {integrity: sha512-g9bc4YDiy4g/peLsUDmVcy2q/QXI3eHCQtHrVp2sHWef2SYjwUJ2+TOtJHScO8LuKhGnU3h2UeE59tPWTF2quw==} peerDependencies: - storybook: ^10.2.19 + storybook: ^10.3.0 - '@storybook/addon-links@10.2.19': - resolution: {integrity: sha512-3uZqbjlEmPg+X82nkwjBHshaCjDKLT75/NnbscvXPBiIl7ew+lhKDnufYLhChQmuAKQqfqBhdtwa2ISzw+O1XQ==} + '@storybook/addon-links@10.3.0': + resolution: {integrity: sha512-F0/UPO3HysoJoAFrBSqWkRP3lK2owHSAgQNEFB9mNihsAQbHHg9xer22VROL012saprs98+V/hNUZs4zPy9zlg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.19 + storybook: ^10.3.0 peerDependenciesMeta: react: optional: true - '@storybook/addon-onboarding@10.2.19': - resolution: {integrity: sha512-nlsQrdVMRFpbdimouLJHmCOCSsa3J1ASgDT6ipuE1owpUltg7Dj2PdUg/JRaWE6ghlP7MOP5see6vvnUgZ0Feg==} + '@storybook/addon-onboarding@10.3.0': + resolution: {integrity: sha512-zhSmxO1VDntnAxSCvw1R9h2+KvAnY0PeDdhyrr9hQdVL1j3SEXxegc3dm/YJRhtBk6S2KPLgPU5+UQuFF0p2nA==} peerDependencies: - storybook: ^10.2.19 + storybook: ^10.3.0 - '@storybook/addon-themes@10.2.19': - resolution: {integrity: sha512-TzcX/aqzZrQUypDATywLOenVoa1CTXBthODoY9odLsLLrxVaoeqsAdulkmOjeppKR1FigcERyIjIWPB8W48dag==} + '@storybook/addon-themes@10.3.0': + resolution: {integrity: sha512-tMNRnEXv91u2lYgyUUAPhWiPD2XTLw2prj6r9/e9wmKYqJ5a2q0gQ7MiGzbgNYWmqq+DZ7g4vvGt8MXt2GmSHQ==} peerDependencies: - storybook: ^10.2.19 + storybook: ^10.3.0 - '@storybook/builder-vite@10.2.19': - resolution: {integrity: sha512-a59xALzM9GeYh6p+wzAeBbDyIe+qyrC4nxS3QNzb5i2ZOhrq1iIpvnDaOWe80NC8mV3IlqUEGY8Uawkf//1Rmg==} + '@storybook/builder-vite@10.3.0': + resolution: {integrity: sha512-T7LfZPE31j94Jkk66bnsxMibBnbLYmebLIDgPSYzeN3ZkjPfoFhhi2+8Zxneth5cQCGRkCAhRTV0tYmFp1+H6g==} peerDependencies: - storybook: ^10.2.19 + storybook: ^10.3.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.2.19': - resolution: {integrity: sha512-BpjYIOdyQn/Rm6MjUAc5Gl8HlARZrskD/OhUNShiOh2fznb523dHjiE5mbU1kKM/+L1uvRlEqqih40rTx+xCrg==} + '@storybook/csf-plugin@10.3.0': + resolution: {integrity: sha512-zlBnNpv0wtmICdQPDoY91HNzn6BNqnS2hur580J+qJtcP+5ZOYU7+gNyU+vfAnQuLEWbPz34rx8b1cTzXZQCDg==} peerDependencies: esbuild: 0.27.2 rollup: 4.59.0 - storybook: ^10.2.19 + storybook: ^10.3.0 vite: '*' webpack: '*' peerDependenciesMeta: @@ -2930,40 +2927,40 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@storybook/nextjs-vite@10.2.19': - resolution: {integrity: sha512-K8w3L9dprm1XathTYWSMx6KBsyyBs07GkrHz0SPpalvibmre0i9YzTGSD0LpdSKtjGfwpsvwunY3RajMs66FvA==} + '@storybook/nextjs-vite@10.3.0': + resolution: {integrity: sha512-PQSQiUVxiR3eO3lmGbSyuPAbVwNJpOQDzkiC337IqWHhzZZQFVRgGU9j39hsUiP/d23BVuXPOWZtmTPASXDVMQ==} peerDependencies: next: ^14.1.0 || ^15.0.0 || ^16.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.19 + storybook: ^10.3.0 typescript: '*' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react-dom-shim@10.2.19': - resolution: {integrity: sha512-BXCEfBGVBRYBTYeBeH/PJsy0Bq5MERe/HiaylR+ah/XrvIr2Z9bkne1J8yYiXCjiyq5HQa7Bj11roz0+vyUaEw==} + '@storybook/react-dom-shim@10.3.0': + resolution: {integrity: sha512-dmAnIjkMmUYZCdg3FUL83Lavybin3bYKRNRXFZq1okCH8SINa2J+zKEzJhPlqixAKkbd7x1PFDgXnxxM/Nisig==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.19 + storybook: ^10.3.0 - '@storybook/react-vite@10.2.19': - resolution: {integrity: sha512-2/yMKrK4IqMIZicRpPMoIg+foBuWnkaEWt0R4V4hjErDj/SC3D9ov+GUqhjKJ81TegijhKzNpwnSD7Nf87haKw==} + '@storybook/react-vite@10.3.0': + resolution: {integrity: sha512-34t+30j+gglcRchPuZx4S4uusD746cvPeUPli7iJRWd3+vpnHSct03uGFAlsVJo6DZvVgH5s7vP4QU66C76K8A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.19 + storybook: ^10.3.0 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/react@10.2.19': - resolution: {integrity: sha512-gm2qxLyYSsGp7fee5i+d8jSVUKMla8yRaTJ1wxPEnyaJMd0QUu6U2v3p2rW7PH1DWop3D6NqWOY8kmZjmSZKlA==} + '@storybook/react@10.3.0': + resolution: {integrity: sha512-pN++HZYVwjyJWeNg+6cewjOPkWlSho+BaUxCq/2e6yYUCr1J6MkBCYN/l1F7/ex9pDTKv9AW0da0o1aRXm3ivg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.2.19 + storybook: ^10.3.0 typescript: '>= 4.9.x' peerDependenciesMeta: typescript: @@ -3076,11 +3073,11 @@ packages: peerDependencies: solid-js: 1.9.11 - '@tanstack/eslint-plugin-query@5.91.4': - resolution: {integrity: sha512-8a+GAeR7oxJ5laNyYBQ6miPK09Hi18o5Oie/jx8zioXODv/AUFLZQecKabPdpQSLmuDXEBPKFh+W5DKbWlahjQ==} + '@tanstack/eslint-plugin-query@5.91.5': + resolution: {integrity: sha512-4pqgoT5J+ntkyOoBtnxJu8LYRj3CurfNe92fghJw66mI7pZijKmOulM32Wa48cyVzGtgiuQ2o5KWC9LJVXYcBQ==} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - typescript: ^5.0.0 + typescript: ^5.4.0 peerDependenciesMeta: typescript: optional: true @@ -3097,8 +3094,8 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.90.20': - resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + '@tanstack/query-core@5.91.0': + resolution: {integrity: sha512-FYXN8Kk9Q5VKuV6AIVaNwMThSi0nvAtR4X7HQoigf6ePOtFcavJYVIzgFhOVdtbBQtCJE3KimDIMMJM2DR1hjw==} '@tanstack/query-devtools@5.93.0': resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} @@ -3132,8 +3129,8 @@ packages: '@tanstack/react-query': ^5.90.20 react: ^18 || ^19 - '@tanstack/react-query@5.90.21': - resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + '@tanstack/react-query@5.91.0': + resolution: {integrity: sha512-S8FODsDTNv0Ym+o/JVBvA6EWiWVhg6K2Q4qFehZyFKk6uW4H9OPbXl4kyiN9hAly0uHJ/1GEbR6kAI4MZWfjEA==} peerDependencies: react: ^18 || ^19 @@ -3420,9 +3417,6 @@ packages: '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/sortablejs@1.15.9': resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} @@ -3506,43 +3500,43 @@ packages: resolution: {integrity: sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-E63cwlaAKeOXGcSaTcuVKdfGQJoms/vSMZH8lYsvxU6el3X96LdbXVcAIqbqwDfPJt7rQ6gw/OOGO45BlTlxSw==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-hsXZC0M5N2F/KdX/wjRywZPovdGBgWw9ARy0GWCw1dAynqdfDcuceKbUw+QwMSdvvsFbUjSomTlyFdT09p1mcA==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-cy9kmUiwJmANoz1tOc22HYXqyz92tvNrI9eP/q8LAa5LGega5OlTqAbuSiEc4C9OUL/4EvCJuIYmOk5PFME0tQ==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-lQl7DQkROqPZrx4C1MpFP0WNxdqv+9r4lErhd+57M2Kmxx1BmX3K5VMLJT9FZQFRtgntnYbwQAQ774Z17fv8rA==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-YeRI5O4z5H7JgBCb2tTaZSEEEpojjBxac5DQ1NEm3wwjSSWhHadCaq/mDLq8yWDQo9JK0Yuj3Vb0NK3/P7F9Sw==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-1wv0qpJW4okKadShemVi4s7zGuiIRI7zTInRYDV/FfyQVyKrkTOzMtZXB6CF3Reus1HmRpGp5ADyc4MI7CCeJg==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-Q6vKYOej5FoPfYzvNsUdeE4GWWKxUg9wzo2fJxgV50ZQeITEDvSpG1PxS1019kFbe3KSAoIIzSmhP/4EIie7Kg==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-tE7uN00Po/oBg5VYaYM0C/QXroo6gdIRmFVZl543o46ihl0YKEZBMnyStRKKgPCI9oeYXyCNT6WR4MxSMz6ndA==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-PJN4JbWrVeJ8WyWbRWVdyUHUUSL9zKqywwroXdqIDgxUO2B09bSUCCs9DFJNHXQ5NqWND/WeVPgpYfnQ95S21Q==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-aSE7xAKYTOrxsFrIgmcaHjgXSSOnWrZ6ozNBeNxpGzd/gl2Ho3FCIwQb0NCXrDwF9AhpFRtHMWPpAPaJk24+rg==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-Obv+ZTK0NVfv1E/KlTz7G5EyPbE8zNA1qeT59CyoWHl0Mi2iq3njs5dRIfcRbgGDZkSI8wJ+4C4JGfUyZZGZkA==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-TV/Tn8cgWamb+6mvY45X2wF0vrTkQmRFCiN1pRRehEwxslDkqLVlpGAFpZndLaPlMb/wzwVpz1e/926xdAoO1w==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-Trt8E1nphVXaicVcXpsyiVArT6Zf+blhqGP/MPx9YEBlp1nuzxFpu1kj7A75r+6js4vIdoKieK215Q4J358qUw==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-AgOZODSYeTlQWVTioRG3AxHzIBSLbZZhyK19WPzjHW0LtxCcFi59G/Gn1uIshVL3sp1ESRg9SZ5mSiFdgvfK4g==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260317.1': - resolution: {integrity: sha512-HmYNuhDoN9OHfsSHuSRvYJobHAHDVubLvSGSrjfNL6C34fVlPkPDi+iA53LcN1pVX5J30kajcKC7Snm13xmuKA==} + '@typescript/native-preview@7.0.0-dev.20260318.1': + resolution: {integrity: sha512-/7LF/2x29K++k147445omxNixPANTmwJl9p/IIzK8NbOeqVOFv1Gj1GQyOQqRdT4j/X6YDwO/p400/JKE+cBOw==} hasBin: true '@ungap/structured-clone@1.3.0': @@ -4919,11 +4913,11 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 - eslint-plugin-storybook@10.2.19: - resolution: {integrity: sha512-JwwNgG24mkwwiJp/VIwUuJ9QIXoeCZteSZ7PEpb8DUhKpzCrNxJOeg7i5ep6yLexWAVjfFLG4OnFeV8cVS2PAg==} + eslint-plugin-storybook@10.3.0: + resolution: {integrity: sha512-8R0/RjELXkJ2RxPusX14ZiIj1So90bPnrjbxmQx1BD+4M2VoMHfn3n+6IvzJWQH4FT5tMRRUBqjLBe1fJjRRkg==} peerDependencies: eslint: '>=8' - storybook: ^10.2.19 + storybook: ^10.3.0 eslint-plugin-toml@1.3.1: resolution: {integrity: sha512-1l00fBP03HIt9IPV7ZxBi7x0y0NMdEZmakL1jBD6N/FoKBvfKxPw5S8XkmzBecOnFBTn5Z8sNJtL5vdf9cpRMQ==} @@ -5464,6 +5458,7 @@ packages: intersection-observer@0.12.2: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} + deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. is-alphabetical@1.0.4: resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==} @@ -5689,8 +5684,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@5.87.0: - resolution: {integrity: sha512-oJBrwd4/Mt5E6817vcdQLaPpejxZTxpASauYLkp6HaT0HN1seHnpF96KEjza9O8yARvHEQ9+So9AFUjkPci7dQ==} + knip@5.88.0: + resolution: {integrity: sha512-FZjQYLYwUbVrtC3C1cKyEMMqR4K2ZlkQLZszJgF5cfDo4GUSBZAdAV0P3eyzZrkssRoghLJQA9HTQUW7G+Tc8Q==} engines: {node: '>=18.18.0'} hasBin: true peerDependencies: @@ -6221,8 +6216,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.1.7: - resolution: {integrity: sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==} + next@16.2.0: + resolution: {integrity: sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -6571,6 +6566,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -7115,8 +7111,12 @@ packages: std-env@4.0.0: resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} - storybook@10.2.19: - resolution: {integrity: sha512-UUm5eGSm6BLhkcFP0WbxkmAHJZfVN2ViLpIZOqiIPS++q32VYn+CLFC0lrTYTDqYvaG7i4BK4uowXYujzE4NdQ==} + std-semver@1.0.8: + resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==} + engines: {node: '>=20.19.0'} + + storybook@10.3.0: + resolution: {integrity: sha512-OpLdng98l7cACuqBoQwewx21Vhgl9XPssgLdXQudW0+N5QPjinWXZpZCquZpXpNCyw5s5BFAcv+jKB3Qkf9jeA==} hasBin: true peerDependencies: prettier: ^2 || ^3 @@ -7791,6 +7791,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -8085,7 +8086,7 @@ snapshots: idb: 8.0.0 tslib: 2.8.1 - '@antfu/eslint-config@7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.7)(@typescript-eslint/rule-tester@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.12(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(jiti@1.21.7)(jsdom@29.0.0(canvas@3.2.1))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.3(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.3(jiti@1.21.7)))(eslint@10.0.3(jiti@1.21.7))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(typescript@5.9.3)': + '@antfu/eslint-config@7.7.3(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.2.0)(@typescript-eslint/rule-tester@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.57.1(typescript@5.9.3))(@typescript-eslint/utils@8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3))(@voidzero-dev/vite-plus-test@0.1.12(@types/node@25.5.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(jiti@1.21.7)(jsdom@29.0.0(canvas@3.2.1))(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(@vue/compiler-sfc@3.5.30)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.3(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.3(jiti@1.21.7)))(eslint@10.0.3(jiti@1.21.7))(oxlint@1.55.0(oxlint-tsgolint@0.17.0))(typescript@5.9.3)': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.1.0 @@ -8126,7 +8127,7 @@ snapshots: yaml-eslint-parser: 2.0.0 optionalDependencies: '@eslint-react/eslint-plugin': 2.13.0(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3) - '@next/eslint-plugin-next': 16.1.7 + '@next/eslint-plugin-next': 16.2.0 eslint-plugin-react-hooks: 7.0.1(eslint@10.0.3(jiti@1.21.7)) eslint-plugin-react-refresh: 0.5.2(eslint@10.0.3(jiti@1.21.7)) transitivePeerDependencies: @@ -8324,13 +8325,13 @@ snapshots: '@chevrotain/utils@11.1.2': {} - '@chromatic-com/storybook@5.0.1(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@chromatic-com/storybook@5.0.1(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@neoconfetti/react': 1.0.0 chromatic: 13.3.5 filesize: 10.1.6 jsonfile: 6.2.0 - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) strip-ansi: 7.2.0 transitivePeerDependencies: - '@chromatic-com/cypress' @@ -8359,7 +8360,7 @@ snapshots: '@code-inspector/core@1.4.4': dependencies: '@vue/compiler-dom': 3.5.30 - chalk: 4.1.1 + chalk: 4.1.2 dotenv: 16.6.1 launch-ide: 1.4.3 portfinder: 1.0.38 @@ -9220,41 +9221,41 @@ snapshots: '@next/env@16.0.0': {} - '@next/env@16.1.7': {} + '@next/env@16.2.0': {} - '@next/eslint-plugin-next@16.1.7': + '@next/eslint-plugin-next@16.2.0': dependencies: fast-glob: 3.3.1 - '@next/mdx@16.1.7(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4))': + '@next/mdx@16.2.0(@mdx-js/loader@3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4))': dependencies: source-map: 0.7.6 optionalDependencies: '@mdx-js/loader': 3.1.1(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@next/swc-darwin-arm64@16.1.7': + '@next/swc-darwin-arm64@16.2.0': optional: true - '@next/swc-darwin-x64@16.1.7': + '@next/swc-darwin-x64@16.2.0': optional: true - '@next/swc-linux-arm64-gnu@16.1.7': + '@next/swc-linux-arm64-gnu@16.2.0': optional: true - '@next/swc-linux-arm64-musl@16.1.7': + '@next/swc-linux-arm64-musl@16.2.0': optional: true - '@next/swc-linux-x64-gnu@16.1.7': + '@next/swc-linux-x64-gnu@16.2.0': optional: true - '@next/swc-linux-x64-musl@16.1.7': + '@next/swc-linux-x64-musl@16.2.0': optional: true - '@next/swc-win32-arm64-msvc@16.1.7': + '@next/swc-win32-arm64-msvc@16.2.0': optional: true - '@next/swc-win32-x64-msvc@16.1.7': + '@next/swc-win32-x64-msvc@16.2.0': optional: true '@nodelib/fs.scandir@2.1.5': @@ -9326,63 +9327,63 @@ snapshots: '@open-draft/until@2.1.0': {} - '@orpc/client@1.13.7': + '@orpc/client@1.13.8': dependencies: - '@orpc/shared': 1.13.7 - '@orpc/standard-server': 1.13.7 - '@orpc/standard-server-fetch': 1.13.7 - '@orpc/standard-server-peer': 1.13.7 + '@orpc/shared': 1.13.8 + '@orpc/standard-server': 1.13.8 + '@orpc/standard-server-fetch': 1.13.8 + '@orpc/standard-server-peer': 1.13.8 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/contract@1.13.7': + '@orpc/contract@1.13.8': dependencies: - '@orpc/client': 1.13.7 - '@orpc/shared': 1.13.7 + '@orpc/client': 1.13.8 + '@orpc/shared': 1.13.8 '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/openapi-client@1.13.7': + '@orpc/openapi-client@1.13.8': dependencies: - '@orpc/client': 1.13.7 - '@orpc/contract': 1.13.7 - '@orpc/shared': 1.13.7 - '@orpc/standard-server': 1.13.7 + '@orpc/client': 1.13.8 + '@orpc/contract': 1.13.8 + '@orpc/shared': 1.13.8 + '@orpc/standard-server': 1.13.8 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/shared@1.13.7': + '@orpc/shared@1.13.8': dependencies: radash: 12.1.1 type-fest: 5.4.4 - '@orpc/standard-server-fetch@1.13.7': + '@orpc/standard-server-fetch@1.13.8': dependencies: - '@orpc/shared': 1.13.7 - '@orpc/standard-server': 1.13.7 + '@orpc/shared': 1.13.8 + '@orpc/standard-server': 1.13.8 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/standard-server-peer@1.13.7': + '@orpc/standard-server-peer@1.13.8': dependencies: - '@orpc/shared': 1.13.7 - '@orpc/standard-server': 1.13.7 + '@orpc/shared': 1.13.8 + '@orpc/standard-server': 1.13.8 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/standard-server@1.13.7': + '@orpc/standard-server@1.13.8': dependencies: - '@orpc/shared': 1.13.7 + '@orpc/shared': 1.13.8 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.13.7(@orpc/client@1.13.7)(@tanstack/query-core@5.90.20)': + '@orpc/tanstack-query@1.13.8(@orpc/client@1.13.8)(@tanstack/query-core@5.91.0)': dependencies: - '@orpc/client': 1.13.7 - '@orpc/shared': 1.13.7 - '@tanstack/query-core': 5.90.20 + '@orpc/client': 1.13.8 + '@orpc/shared': 1.13.8 + '@tanstack/query-core': 5.91.0 transitivePeerDependencies: - '@opentelemetry/api' @@ -10124,15 +10125,15 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@storybook/addon-docs@10.2.19(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/addon-docs@10.3.0(@types/react@19.2.14)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) - '@storybook/csf-plugin': 10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + '@storybook/csf-plugin': 10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-dom-shim': 10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/react-dom-shim': 10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -10141,26 +10142,26 @@ snapshots: - vite - webpack - '@storybook/addon-links@10.2.19(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-links@10.3.0(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: '@storybook/global': 5.0.0 - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: react: 19.2.4 - '@storybook/addon-onboarding@10.2.19(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-onboarding@10.3.0(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/addon-themes@10.2.19(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/addon-themes@10.3.0(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/builder-vite@10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: - '@storybook/csf-plugin': 10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@storybook/csf-plugin': 10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)' transitivePeerDependencies: @@ -10168,9 +10169,9 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/csf-plugin@10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: esbuild: 0.27.2 @@ -10185,18 +10186,18 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/nextjs-vite@10.2.19(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/nextjs-vite@10.3.0(@babel/core@7.29.0)(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: - '@storybook/builder-vite': 10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@storybook/react': 10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) - '@storybook/react-vite': 10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + '@storybook/builder-vite': 10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + '@storybook/react': 10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@storybook/react-vite': 10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + next: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) vite: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)' - vite-plugin-storybook-nextjs: 3.2.3(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + vite-plugin-storybook-nextjs: 3.2.3(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -10207,25 +10208,25 @@ snapshots: - supports-color - webpack - '@storybook/react-dom-shim@10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@storybook/react-dom-shim@10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@storybook/react-vite@10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': + '@storybook/react-vite@10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3))': dependencies: '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(typescript@5.9.3) '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@storybook/builder-vite': 10.2.19(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) - '@storybook/react': 10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + '@storybook/builder-vite': 10.3.0(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.2)(rollup@4.59.0)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)) + '@storybook/react': 10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 react: 19.2.4 react-docgen: 8.0.3 react-dom: 19.2.4(react@19.2.4) resolve: 1.22.11 - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tsconfig-paths: 4.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)' transitivePeerDependencies: @@ -10235,14 +10236,15 @@ snapshots: - typescript - webpack - '@storybook/react@10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': + '@storybook/react@10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.2.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@storybook/react-dom-shim': 10.3.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) react: 19.2.4 react-docgen: 8.0.3 + react-docgen-typescript: 2.4.0(typescript@5.9.3) react-dom: 19.2.4(react@19.2.4) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -10340,7 +10342,7 @@ snapshots: - csstype - utf-8-validate - '@tanstack/eslint-plugin-query@5.91.4(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3)': + '@tanstack/eslint-plugin-query@5.91.5(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3) eslint: 10.0.3(jiti@1.21.7) @@ -10373,7 +10375,7 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.90.20': {} + '@tanstack/query-core@5.91.0': {} '@tanstack/query-devtools@5.93.0': {} @@ -10410,15 +10412,15 @@ snapshots: transitivePeerDependencies: - react-dom - '@tanstack/react-query-devtools@5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4)': + '@tanstack/react-query-devtools@5.91.3(@tanstack/react-query@5.91.0(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/query-devtools': 5.93.0 - '@tanstack/react-query': 5.90.21(react@19.2.4) + '@tanstack/react-query': 5.91.0(react@19.2.4) react: 19.2.4 - '@tanstack/react-query@5.90.21(react@19.2.4)': + '@tanstack/react-query@5.91.0(react@19.2.4)': dependencies: - '@tanstack/query-core': 5.90.20 + '@tanstack/query-core': 5.91.0 react: 19.2.4 '@tanstack/react-store@0.9.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -10755,8 +10757,6 @@ snapshots: '@types/resolve@1.20.6': {} - '@types/semver@7.7.1': {} - '@types/sortablejs@1.15.9': {} '@types/trusted-types@2.0.7': @@ -10890,36 +10890,36 @@ snapshots: '@typescript-eslint/types': 8.57.1 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260317.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260317.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260317.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260317.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260317.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260317.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260317.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260318.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260317.1': + '@typescript/native-preview@7.0.0-dev.20260318.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260317.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260317.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260317.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260317.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260317.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260317.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260317.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260318.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260318.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260318.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260318.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260318.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260318.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260318.1 '@ungap/structured-clone@1.3.0': {} @@ -10927,13 +10927,13 @@ snapshots: dependencies: unpic: 4.2.2 - '@unpic/react@1.0.2(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@unpic/react@1.0.2(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@unpic/core': 1.0.3 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) '@upsetjs/venn.js@2.0.0': optionalDependencies: @@ -12415,11 +12415,11 @@ snapshots: ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 - eslint-plugin-storybook@10.2.19(eslint@10.0.3(jiti@1.21.7))(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): + eslint-plugin-storybook@10.3.0(eslint@10.0.3(jiti@1.21.7))(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.57.1(eslint@10.0.3(jiti@1.21.7))(typescript@5.9.3) eslint: 10.0.3(jiti@1.21.7) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - supports-color - typescript @@ -13289,7 +13289,7 @@ snapshots: khroma@2.1.0: {} - knip@5.87.0(@types/node@25.5.0)(typescript@5.9.3): + knip@5.88.0(@types/node@25.5.0)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 '@types/node': 25.5.0 @@ -13325,7 +13325,7 @@ snapshots: launch-ide@1.4.3: dependencies: - chalk: 4.1.1 + chalk: 4.1.2 dotenv: 16.6.1 layout-base@1.0.2: {} @@ -14120,9 +14120,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0): + next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0): dependencies: - '@next/env': 16.1.7 + '@next/env': 16.2.0 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001780 @@ -14131,14 +14131,14 @@ snapshots: react-dom: 19.2.4(react@19.2.4) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: - '@next/swc-darwin-arm64': 16.1.7 - '@next/swc-darwin-x64': 16.1.7 - '@next/swc-linux-arm64-gnu': 16.1.7 - '@next/swc-linux-arm64-musl': 16.1.7 - '@next/swc-linux-x64-gnu': 16.1.7 - '@next/swc-linux-x64-musl': 16.1.7 - '@next/swc-win32-arm64-msvc': 16.1.7 - '@next/swc-win32-x64-msvc': 16.1.7 + '@next/swc-darwin-arm64': 16.2.0 + '@next/swc-darwin-x64': 16.2.0 + '@next/swc-linux-arm64-gnu': 16.2.0 + '@next/swc-linux-arm64-musl': 16.2.0 + '@next/swc-linux-x64-gnu': 16.2.0 + '@next/swc-linux-x64-musl': 16.2.0 + '@next/swc-win32-arm64-msvc': 16.2.0 + '@next/swc-win32-x64-msvc': 16.2.0 sass: 1.98.0 sharp: 0.34.5 transitivePeerDependencies: @@ -14171,12 +14171,12 @@ snapshots: dependencies: boolbase: 1.0.0 - nuqs@2.8.9(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4): + nuqs@2.8.9(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react@19.2.4): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.4 optionalDependencies: - next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + next: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) object-assign@4.1.1: {} @@ -15205,7 +15205,9 @@ snapshots: std-env@4.0.0: {} - storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + std-semver@1.0.8: {} + + storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -15752,9 +15754,9 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.31(cde6d75387d76fb961ef48e4fe505e24): + vinext@0.0.31(d43efe4756ad5ea698dcdb002ea787ea): dependencies: - '@unpic/react': 1.0.2(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@unpic/react': 1.0.2(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vercel/og': 0.8.6 '@vitejs/plugin-react': 6.0.1(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) magic-string: 0.30.21 @@ -15811,14 +15813,14 @@ snapshots: transitivePeerDependencies: - supports-color - vite-plugin-storybook-nextjs@3.2.3(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(next@16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): + vite-plugin-storybook-nextjs@3.2.3(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0))(storybook@10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): dependencies: '@next/env': 16.0.0 image-size: 2.0.2 magic-string: 0.30.21 module-alias: 2.3.4 - next: 16.1.7(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) - storybook: 10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.98.0) + storybook: 10.3.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 vite: '@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)' vite-tsconfig-paths: 5.1.4(@voidzero-dev/vite-plus-core@0.1.12(@types/node@25.5.0)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(typescript@5.9.3) diff --git a/web/scripts/check-components-diff-coverage-lib.mjs b/web/scripts/check-components-diff-coverage-lib.mjs deleted file mode 100644 index 9436bf9453..0000000000 --- a/web/scripts/check-components-diff-coverage-lib.mjs +++ /dev/null @@ -1,407 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' - -const DIFF_COVERAGE_IGNORE_LINE_TOKEN = 'diff-coverage-ignore-line:' -const DEFAULT_BRANCH_REF_CANDIDATES = ['origin/main', 'main'] - -export function normalizeDiffRangeMode(mode) { - return mode === 'exact' ? 'exact' : 'merge-base' -} - -export function buildGitDiffRevisionArgs(base, head, mode = 'merge-base') { - return mode === 'exact' - ? [base, head] - : [`${base}...${head}`] -} - -export function resolveGitDiffContext({ - base, - head, - mode = 'merge-base', - execGit, -}) { - const requestedMode = normalizeDiffRangeMode(mode) - const context = { - base, - head, - mode: requestedMode, - requestedMode, - reason: null, - useCombinedMergeDiff: false, - } - - if (requestedMode !== 'exact' || !base || !head || !execGit) - return context - - const baseCommit = resolveCommitSha(base, execGit) ?? base - const headCommit = resolveCommitSha(head, execGit) ?? head - const parents = getCommitParents(headCommit, execGit) - if (parents.length < 2) - return context - - const [firstParent, secondParent] = parents - if (firstParent !== baseCommit) - return context - - const defaultBranchRef = resolveDefaultBranchRef(execGit) - if (!defaultBranchRef || !isAncestor(secondParent, defaultBranchRef, execGit)) - return context - - return { - ...context, - reason: `ignored merge from ${defaultBranchRef}`, - useCombinedMergeDiff: true, - } -} - -export function parseChangedLineMap(diff, isTrackedComponentSourceFile) { - const lineMap = new Map() - let currentFile = null - - for (const line of diff.split('\n')) { - if (line.startsWith('+++ b/')) { - currentFile = line.slice(6).trim() - continue - } - - if (!currentFile || !isTrackedComponentSourceFile(currentFile)) - continue - - const match = line.match(/^@{2,}(?: -\d+(?:,\d+)?)+ \+(\d+)(?:,(\d+))? @{2,}/) - if (!match) - continue - - const start = Number(match[1]) - const count = match[2] ? Number(match[2]) : 1 - if (count === 0) - continue - - const linesForFile = lineMap.get(currentFile) ?? new Set() - for (let offset = 0; offset < count; offset += 1) - linesForFile.add(start + offset) - lineMap.set(currentFile, linesForFile) - } - - return lineMap -} - -export function normalizeToRepoRelative(filePath, { - appComponentsCoveragePrefix, - appComponentsPrefix, - repoRoot, - sharedTestPrefix, - webRoot, -}) { - if (!filePath) - return '' - - if (filePath.startsWith(appComponentsPrefix) || filePath.startsWith(sharedTestPrefix)) - return filePath - - if (filePath.startsWith(appComponentsCoveragePrefix)) - return `web/${filePath}` - - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(webRoot, filePath) - - return path.relative(repoRoot, absolutePath).split(path.sep).join('/') -} - -export function getLineHits(entry) { - if (entry?.l && Object.keys(entry.l).length > 0) - return entry.l - - const lineHits = {} - for (const [statementId, statement] of Object.entries(entry?.statementMap ?? {})) { - const line = statement?.start?.line - if (!line) - continue - - const hits = entry?.s?.[statementId] ?? 0 - const previous = lineHits[line] - lineHits[line] = previous === undefined ? hits : Math.max(previous, hits) - } - - return lineHits -} - -export function getChangedStatementCoverage(entry, changedLines) { - const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b) - if (!entry) { - return { - covered: 0, - total: normalizedChangedLines.length, - uncoveredLines: normalizedChangedLines, - } - } - - const uncoveredLines = [] - let covered = 0 - let total = 0 - - for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) { - if (!rangeIntersectsChangedLines(statement, changedLines)) - continue - - total += 1 - const hits = entry.s?.[statementId] ?? 0 - if (hits > 0) { - covered += 1 - continue - } - - uncoveredLines.push(getFirstChangedLineInRange(statement, normalizedChangedLines)) - } - - return { - covered, - total, - uncoveredLines: uncoveredLines.sort((a, b) => a - b), - } -} - -export function getChangedBranchCoverage(entry, changedLines) { - const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b) - if (!entry) { - return { - covered: 0, - total: 0, - uncoveredBranches: [], - } - } - - const uncoveredBranches = [] - let covered = 0 - let total = 0 - - for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) { - const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : [] - const locations = getBranchLocations(branch) - const armCount = Math.max(locations.length, hits.length) - const impactedArmIndexes = getImpactedBranchArmIndexes(branch, changedLines, armCount) - - if (impactedArmIndexes.length === 0) - continue - - for (const armIndex of impactedArmIndexes) { - total += 1 - if ((hits[armIndex] ?? 0) > 0) { - covered += 1 - continue - } - - const location = locations[armIndex] ?? branch.loc ?? branch - uncoveredBranches.push({ - armIndex, - line: getFirstChangedLineInRange(location, normalizedChangedLines, branch.line ?? 1), - }) - } - } - - uncoveredBranches.sort((a, b) => a.line - b.line || a.armIndex - b.armIndex) - return { - covered, - total, - uncoveredBranches, - } -} - -export function getIgnoredChangedLinesFromFile(filePath, changedLines) { - if (!fs.existsSync(filePath)) - return emptyIgnoreResult(changedLines) - - const sourceCode = fs.readFileSync(filePath, 'utf8') - return getIgnoredChangedLinesFromSource(sourceCode, changedLines) -} - -export function getIgnoredChangedLinesFromSource(sourceCode, changedLines) { - const ignoredLines = new Map() - const invalidPragmas = [] - const changedLineSet = new Set(changedLines ?? []) - - const sourceLines = sourceCode.split('\n') - sourceLines.forEach((lineText, index) => { - const lineNumber = index + 1 - const commentIndex = lineText.indexOf('//') - if (commentIndex < 0) - return - - const tokenIndex = lineText.indexOf(DIFF_COVERAGE_IGNORE_LINE_TOKEN, commentIndex + 2) - if (tokenIndex < 0) - return - - const reason = lineText.slice(tokenIndex + DIFF_COVERAGE_IGNORE_LINE_TOKEN.length).trim() - if (!changedLineSet.has(lineNumber)) - return - - if (!reason) { - invalidPragmas.push({ - line: lineNumber, - reason: 'missing ignore reason', - }) - return - } - - ignoredLines.set(lineNumber, reason) - }) - - const effectiveChangedLines = new Set( - [...changedLineSet].filter(lineNumber => !ignoredLines.has(lineNumber)), - ) - - return { - effectiveChangedLines, - ignoredLines, - invalidPragmas, - } -} - -function emptyIgnoreResult(changedLines = []) { - return { - effectiveChangedLines: new Set(changedLines), - ignoredLines: new Map(), - invalidPragmas: [], - } -} - -function getCommitParents(ref, execGit) { - const output = tryExecGit(execGit, ['rev-list', '--parents', '-n', '1', ref]) - if (!output) - return [] - - return output - .trim() - .split(/\s+/) - .slice(1) -} - -function resolveCommitSha(ref, execGit) { - return tryExecGit(execGit, ['rev-parse', '--verify', ref])?.trim() ?? null -} - -function resolveDefaultBranchRef(execGit) { - const originHeadRef = tryExecGit(execGit, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])?.trim() - if (originHeadRef) - return originHeadRef - - for (const ref of DEFAULT_BRANCH_REF_CANDIDATES) { - if (tryExecGit(execGit, ['rev-parse', '--verify', '-q', ref])) - return ref - } - - return null -} - -function isAncestor(ancestorRef, descendantRef, execGit) { - try { - execGit(['merge-base', '--is-ancestor', ancestorRef, descendantRef]) - return true - } - catch { - return false - } -} - -function tryExecGit(execGit, args) { - try { - return execGit(args) - } - catch { - return null - } -} - -function getBranchLocations(branch) { - return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : [] -} - -function getImpactedBranchArmIndexes(branch, changedLines, armCount) { - if (!changedLines || changedLines.size === 0 || armCount === 0) - return [] - - const locations = getBranchLocations(branch) - if (isWholeBranchTouched(branch, changedLines, locations, armCount)) - return Array.from({ length: armCount }, (_, armIndex) => armIndex) - - const impactedArmIndexes = [] - for (let armIndex = 0; armIndex < armCount; armIndex += 1) { - const location = locations[armIndex] - if (rangeIntersectsChangedLines(location, changedLines)) - impactedArmIndexes.push(armIndex) - } - - return impactedArmIndexes -} - -function isWholeBranchTouched(branch, changedLines, locations, armCount) { - if (!changedLines || changedLines.size === 0) - return false - - if (branch.line && changedLines.has(branch.line)) - return true - - const branchRange = branch.loc ?? branch - if (!rangeIntersectsChangedLines(branchRange, changedLines)) - return false - - if (locations.length === 0 || locations.length < armCount) - return true - - for (const lineNumber of changedLines) { - if (!lineTouchesLocation(lineNumber, branchRange)) - continue - if (!locations.some(location => lineTouchesLocation(lineNumber, location))) - return true - } - - return false -} - -function rangeIntersectsChangedLines(location, changedLines) { - if (!location || !changedLines || changedLines.size === 0) - return false - - const startLine = getLocationStartLine(location) - const endLine = getLocationEndLine(location) ?? startLine - if (!startLine || !endLine) - return false - - for (const lineNumber of changedLines) { - if (lineNumber >= startLine && lineNumber <= endLine) - return true - } - - return false -} - -function getFirstChangedLineInRange(location, changedLines, fallbackLine = 1) { - const startLine = getLocationStartLine(location) - const endLine = getLocationEndLine(location) ?? startLine - if (!startLine || !endLine) - return startLine ?? fallbackLine - - for (const lineNumber of changedLines) { - if (lineNumber >= startLine && lineNumber <= endLine) - return lineNumber - } - - return startLine ?? fallbackLine -} - -function lineTouchesLocation(lineNumber, location) { - const startLine = getLocationStartLine(location) - const endLine = getLocationEndLine(location) ?? startLine - if (!startLine || !endLine) - return false - - return lineNumber >= startLine && lineNumber <= endLine -} - -function getLocationStartLine(location) { - return location?.start?.line ?? location?.line ?? null -} - -function getLocationEndLine(location) { - return location?.end?.line ?? location?.line ?? null -} diff --git a/web/scripts/check-components-diff-coverage-lib.spec.ts b/web/scripts/check-components-diff-coverage-lib.spec.ts deleted file mode 100644 index 4c99193e8e..0000000000 --- a/web/scripts/check-components-diff-coverage-lib.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseChangedLineMap, resolveGitDiffContext } from './check-components-diff-coverage-lib.mjs' - -function createExecGitMock(responses: Record) { - return vi.fn((args: string[]) => { - const key = args.join(' ') - const response = responses[key] - - if (response instanceof Error) - throw response - - if (response === undefined) - throw new Error(`Unexpected git args: ${key}`) - - return response - }) -} - -describe('resolveGitDiffContext', () => { - it('switches exact diff to combined merge diff when head merges origin/main into the branch', () => { - const execGit = createExecGitMock({ - 'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n', - 'rev-parse --verify merge-sha': 'merge-sha\n', - 'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha main-parent-sha\n', - 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main\n', - 'merge-base --is-ancestor main-parent-sha origin/main': '', - }) - - expect(resolveGitDiffContext({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - execGit, - })).toEqual({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - requestedMode: 'exact', - reason: 'ignored merge from origin/main', - useCombinedMergeDiff: true, - }) - }) - - it('falls back to origin/main when origin/HEAD is unavailable', () => { - const execGit = createExecGitMock({ - 'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n', - 'rev-parse --verify merge-sha': 'merge-sha\n', - 'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha main-parent-sha\n', - 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': new Error('missing origin/HEAD'), - 'rev-parse --verify -q origin/main': 'main-tip-sha\n', - 'merge-base --is-ancestor main-parent-sha origin/main': '', - }) - - expect(resolveGitDiffContext({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - execGit, - })).toEqual({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - requestedMode: 'exact', - reason: 'ignored merge from origin/main', - useCombinedMergeDiff: true, - }) - }) - - it('keeps exact diff when the second parent is not the default branch', () => { - const execGit = createExecGitMock({ - 'rev-parse --verify feature-parent-sha': 'feature-parent-sha\n', - 'rev-parse --verify merge-sha': 'merge-sha\n', - 'rev-list --parents -n 1 merge-sha': 'merge-sha feature-parent-sha topic-parent-sha\n', - 'symbolic-ref --quiet --short refs/remotes/origin/HEAD': 'origin/main\n', - 'merge-base --is-ancestor topic-parent-sha origin/main': new Error('not ancestor'), - }) - - expect(resolveGitDiffContext({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - execGit, - })).toEqual({ - base: 'feature-parent-sha', - head: 'merge-sha', - mode: 'exact', - requestedMode: 'exact', - reason: null, - useCombinedMergeDiff: false, - }) - }) -}) - -describe('parseChangedLineMap', () => { - it('parses regular diff hunks', () => { - const diff = [ - 'diff --git a/web/app/components/example.tsx b/web/app/components/example.tsx', - '+++ b/web/app/components/example.tsx', - '@@ -10,0 +11,2 @@', - ].join('\n') - - const changedLineMap = parseChangedLineMap(diff, () => true) - - expect([...changedLineMap.get('web/app/components/example.tsx') ?? []]).toEqual([11, 12]) - }) - - it('parses combined merge diff hunks', () => { - const diff = [ - 'diff --cc web/app/components/example.tsx', - '+++ b/web/app/components/example.tsx', - '@@@ -10,0 -10,0 +11,3 @@@', - ].join('\n') - - const changedLineMap = parseChangedLineMap(diff, () => true) - - expect([...changedLineMap.get('web/app/components/example.tsx') ?? []]).toEqual([11, 12, 13]) - }) -}) diff --git a/web/scripts/check-components-diff-coverage.mjs b/web/scripts/check-components-diff-coverage.mjs deleted file mode 100644 index e11d21165c..0000000000 --- a/web/scripts/check-components-diff-coverage.mjs +++ /dev/null @@ -1,362 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { - buildGitDiffRevisionArgs, - getChangedBranchCoverage, - getChangedStatementCoverage, - getIgnoredChangedLinesFromFile, - normalizeDiffRangeMode, - parseChangedLineMap, - resolveGitDiffContext, -} from './check-components-diff-coverage-lib.mjs' -import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs' -import { - APP_COMPONENTS_PREFIX, - createComponentCoverageContext, - getModuleName, - isAnyComponentSourceFile, - isExcludedComponentSourceFile, - isTrackedComponentSourceFile, - loadTrackedCoverageEntries, -} from './components-coverage-common.mjs' -import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs' - -const REQUESTED_DIFF_RANGE_MODE = normalizeDiffRangeMode(process.env.DIFF_RANGE_MODE) -const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') - -const repoRoot = repoRootFromCwd() -const context = createComponentCoverageContext(repoRoot) -const baseSha = process.env.BASE_SHA?.trim() -const headSha = process.env.HEAD_SHA?.trim() || 'HEAD' -const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json') - -if (!baseSha || /^0+$/.test(baseSha)) { - appendSummary([ - '### app/components Pure Diff Coverage', - '', - 'Skipped pure diff coverage check because `BASE_SHA` was not available.', - ]) - process.exit(0) -} - -if (!fs.existsSync(coverageFinalPath)) { - console.error(`Coverage report not found at ${coverageFinalPath}`) - process.exit(1) -} - -const diffContext = resolveGitDiffContext({ - base: baseSha, - head: headSha, - mode: REQUESTED_DIFF_RANGE_MODE, - execGit, -}) -const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8')) -const changedFiles = getChangedFiles(diffContext) -const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile) -const changedSourceFiles = changedComponentSourceFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) -const changedExcludedSourceFiles = changedComponentSourceFiles.filter(filePath => isExcludedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) - -if (changedSourceFiles.length === 0) { - appendSummary(buildSkipSummary(changedExcludedSourceFiles)) - process.exit(0) -} - -const coverageEntries = loadTrackedCoverageEntries(coverage, context) -const diffChanges = getChangedLineMap(diffContext) -const diffRows = [] -const ignoredDiffLines = [] -const invalidIgnorePragmas = [] - -for (const [file, changedLines] of diffChanges.entries()) { - if (!isTrackedComponentSourceFile(file, context.excludedComponentCoverageFiles)) - continue - - const entry = coverageEntries.get(file) - const ignoreInfo = getIgnoredChangedLinesFromFile(path.join(repoRoot, file), changedLines) - - for (const [line, reason] of ignoreInfo.ignoredLines.entries()) { - ignoredDiffLines.push({ - file, - line, - reason, - }) - } - - for (const invalidPragma of ignoreInfo.invalidPragmas) { - invalidIgnorePragmas.push({ - file, - ...invalidPragma, - }) - } - - const statements = getChangedStatementCoverage(entry, ignoreInfo.effectiveChangedLines) - const branches = getChangedBranchCoverage(entry, ignoreInfo.effectiveChangedLines) - diffRows.push({ - branches, - file, - ignoredLineCount: ignoreInfo.ignoredLines.size, - moduleName: getModuleName(file), - statements, - }) -} - -const diffTotals = diffRows.reduce((acc, row) => { - acc.statements.total += row.statements.total - acc.statements.covered += row.statements.covered - acc.branches.total += row.branches.total - acc.branches.covered += row.branches.covered - return acc -}, { - branches: { total: 0, covered: 0 }, - statements: { total: 0, covered: 0 }, -}) - -const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0) -const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0) - -appendSummary(buildSummary({ - changedSourceFiles, - diffContext, - diffBranchFailures, - diffRows, - diffStatementFailures, - diffTotals, - ignoredDiffLines, - invalidIgnorePragmas, -})) - -if (process.env.CI) { - for (const failure of diffStatementFailures.slice(0, 20)) { - const firstLine = failure.statements.uncoveredLines[0] ?? 1 - console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed statements: ${formatLineRanges(failure.statements.uncoveredLines)}`) - } - - for (const failure of diffBranchFailures.slice(0, 20)) { - const firstBranch = failure.branches.uncoveredBranches[0] - const line = firstBranch?.line ?? 1 - console.log(`::error file=${failure.file},line=${line}::Uncovered changed branches: ${formatBranchRefs(failure.branches.uncoveredBranches)}`) - } - - for (const invalidPragma of invalidIgnorePragmas.slice(0, 20)) { - console.log(`::error file=${invalidPragma.file},line=${invalidPragma.line}::Invalid diff coverage ignore pragma: ${invalidPragma.reason}`) - } -} - -if ( - diffStatementFailures.length > 0 - || diffBranchFailures.length > 0 - || invalidIgnorePragmas.length > 0 -) { - process.exit(1) -} - -function buildSummary({ - changedSourceFiles, - diffContext, - diffBranchFailures, - diffRows, - diffStatementFailures, - diffTotals, - ignoredDiffLines, - invalidIgnorePragmas, -}) { - const lines = [ - '### app/components Pure Diff Coverage', - '', - ...buildDiffContextSummary(diffContext), - '', - `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, - `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, - '', - '| Check | Result | Details |', - '|---|---:|---|', - `| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`, - `| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`, - '', - ] - - const changedRows = diffRows - .filter(row => row.statements.total > 0 || row.branches.total > 0) - .sort((a, b) => { - const aScore = percentage(a.statements.covered + a.branches.covered, a.statements.total + a.branches.total) - const bScore = percentage(b.statements.covered + b.branches.covered, b.statements.total + b.branches.total) - return aScore - bScore || a.file.localeCompare(b.file) - }) - - lines.push('
Changed file coverage') - lines.push('') - lines.push('| File | Module | Changed statements | Statement coverage | Uncovered statements | Changed branches | Branch coverage | Uncovered branches | Ignored lines |') - lines.push('|---|---|---:|---:|---|---:|---:|---|---:|') - for (const row of changedRows) { - lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.statements.total} | ${formatDiffPercent(row.statements)} | ${formatLineRanges(row.statements.uncoveredLines)} | ${row.branches.total} | ${formatDiffPercent(row.branches)} | ${formatBranchRefs(row.branches.uncoveredBranches)} | ${row.ignoredLineCount} |`) - } - lines.push('
') - lines.push('') - - if (diffStatementFailures.length > 0) { - lines.push('Uncovered changed statements:') - for (const row of diffStatementFailures) - lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.statements.uncoveredLines)}`) - lines.push('') - } - - if (diffBranchFailures.length > 0) { - lines.push('Uncovered changed branches:') - for (const row of diffBranchFailures) - lines.push(`- ${row.file.replace('web/', '')}: ${formatBranchRefs(row.branches.uncoveredBranches)}`) - lines.push('') - } - - if (ignoredDiffLines.length > 0) { - lines.push('Ignored changed lines via pragma:') - for (const ignoredLine of ignoredDiffLines) - lines.push(`- ${ignoredLine.file.replace('web/', '')}:${ignoredLine.line} - ${ignoredLine.reason}`) - lines.push('') - } - - if (invalidIgnorePragmas.length > 0) { - lines.push('Invalid diff coverage ignore pragmas:') - for (const invalidPragma of invalidIgnorePragmas) - lines.push(`- ${invalidPragma.file.replace('web/', '')}:${invalidPragma.line} - ${invalidPragma.reason}`) - lines.push('') - } - - lines.push(`Changed source files checked: ${changedSourceFiles.length}`) - lines.push('Blocking rules: uncovered changed statements, uncovered changed branches, invalid ignore pragmas.') - - return lines -} - -function buildSkipSummary(changedExcludedSourceFiles) { - const lines = [ - '### app/components Pure Diff Coverage', - '', - ...buildDiffContextSummary(diffContext), - '', - `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, - `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, - '', - ] - - if (changedExcludedSourceFiles.length > 0) { - lines.push('Only excluded component modules or type-only files changed, so pure diff coverage was skipped.') - lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`) - } - else { - lines.push('No tracked source changes under `web/app/components/`. Pure diff coverage skipped.') - } - - return lines -} - -function buildDiffContextSummary(diffContext) { - const lines = [ - `Compared \`${diffContext.base.slice(0, 12)}\` -> \`${diffContext.head.slice(0, 12)}\``, - ] - - if (diffContext.useCombinedMergeDiff) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff strategy: \`combined-merge\` (${diffContext.reason})`) - } - else if (diffContext.reason) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff range mode: \`${diffContext.mode}\` (${diffContext.reason})`) - } - else { - lines.push(`Diff range mode: \`${diffContext.mode}\``) - } - - return lines -} - -function getChangedFiles(diffContext) { - if (diffContext.useCombinedMergeDiff) { - const output = execGit(['diff-tree', '--cc', '--no-commit-id', '--name-only', '-r', diffContext.head, '--', APP_COMPONENTS_PREFIX]) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - } - - const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', APP_COMPONENTS_PREFIX]) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) -} - -function getChangedLineMap(diffContext) { - if (diffContext.useCombinedMergeDiff) { - const diff = execGit(['diff-tree', '--cc', '--no-commit-id', '-r', '--unified=0', diffContext.head, '--', APP_COMPONENTS_PREFIX]) - return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) - } - - const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', APP_COMPONENTS_PREFIX]) - return parseChangedLineMap(diff, filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) -} - -function formatLineRanges(lines) { - if (!lines || lines.length === 0) - return '' - - const ranges = [] - let start = lines[0] - let end = lines[0] - - for (let index = 1; index < lines.length; index += 1) { - const current = lines[index] - if (current === end + 1) { - end = current - continue - } - - ranges.push(start === end ? `${start}` : `${start}-${end}`) - start = current - end = current - } - - ranges.push(start === end ? `${start}` : `${start}-${end}`) - return ranges.join(', ') -} - -function formatBranchRefs(branches) { - if (!branches || branches.length === 0) - return '' - - return branches.map(branch => `${branch.line}[${branch.armIndex}]`).join(', ') -} - -function percentage(covered, total) { - if (total === 0) - return 100 - return (covered / total) * 100 -} - -function formatDiffPercent(metric) { - if (metric.total === 0) - return 'n/a' - - return `${percentage(metric.covered, metric.total).toFixed(2)}%` -} - -function appendSummary(lines) { - const content = `${lines.join('\n')}\n` - if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) - console.log(content) -} - -function execGit(args) { - return execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - }) -} - -function repoRootFromCwd() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: process.cwd(), - encoding: 'utf8', - }).trim() -} diff --git a/web/scripts/component-coverage-filters.mjs b/web/scripts/component-coverage-filters.mjs deleted file mode 100644 index e33c843cb4..0000000000 --- a/web/scripts/component-coverage-filters.mjs +++ /dev/null @@ -1,316 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import tsParser from '@typescript-eslint/parser' - -const TS_TSX_FILE_PATTERN = /\.(?:ts|tsx)$/ -const TYPE_COVERAGE_EXCLUDE_BASENAMES = new Set([ - 'type', - 'types', - 'declarations', -]) -const GENERATED_FILE_COMMENT_PATTERNS = [ - /@generated/i, - /\bauto-?generated\b/i, - /\bgenerated by\b/i, - /\bgenerate by\b/i, - /\bdo not edit\b/i, - /\bdon not edit\b/i, -] -const PARSER_OPTIONS = { - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { jsx: true }, -} - -const collectedExcludedFilesCache = new Map() - -export const COMPONENT_COVERAGE_EXCLUDE_LABEL = 'type-only files, pure barrel files, generated files, pure static files' - -export function isTypeCoverageExcludedComponentFile(filePath) { - return TYPE_COVERAGE_EXCLUDE_BASENAMES.has(getPathBaseNameWithoutExtension(filePath)) -} - -export function getComponentCoverageExclusionReasons(filePath, sourceCode) { - if (!isEligibleComponentSourceFilePath(filePath)) - return [] - - const reasons = [] - if (isTypeCoverageExcludedComponentFile(filePath)) - reasons.push('type-only') - - if (typeof sourceCode !== 'string' || sourceCode.length === 0) - return reasons - - if (isGeneratedComponentFile(sourceCode)) - reasons.push('generated') - - const ast = parseComponentFile(sourceCode) - if (!ast) - return reasons - - if (isPureBarrelComponentFile(ast)) - reasons.push('pure-barrel') - else if (isPureStaticComponentFile(ast)) - reasons.push('pure-static') - - return reasons -} - -export function collectComponentCoverageExcludedFiles(rootDir, options = {}) { - const normalizedRootDir = path.resolve(rootDir) - const pathPrefix = normalizePathPrefix(options.pathPrefix ?? '') - const cacheKey = `${normalizedRootDir}::${pathPrefix}` - const cached = collectedExcludedFilesCache.get(cacheKey) - if (cached) - return cached - - const files = [] - walkComponentFiles(normalizedRootDir, (absolutePath) => { - const relativePath = path.relative(normalizedRootDir, absolutePath).split(path.sep).join('/') - const prefixedPath = pathPrefix ? `${pathPrefix}/${relativePath}` : relativePath - const sourceCode = fs.readFileSync(absolutePath, 'utf8') - if (getComponentCoverageExclusionReasons(prefixedPath, sourceCode).length > 0) - files.push(prefixedPath) - }) - - files.sort((a, b) => a.localeCompare(b)) - collectedExcludedFilesCache.set(cacheKey, files) - return files -} - -function normalizePathPrefix(pathPrefix) { - return pathPrefix.replace(/\\/g, '/').replace(/\/$/, '') -} - -function walkComponentFiles(currentDir, onFile) { - if (!fs.existsSync(currentDir)) - return - - const entries = fs.readdirSync(currentDir, { withFileTypes: true }) - for (const entry of entries) { - const entryPath = path.join(currentDir, entry.name) - if (entry.isDirectory()) { - if (entry.name === '__tests__' || entry.name === '__mocks__') - continue - walkComponentFiles(entryPath, onFile) - continue - } - - if (!isEligibleComponentSourceFilePath(entry.name)) - continue - - onFile(entryPath) - } -} - -function isEligibleComponentSourceFilePath(filePath) { - return TS_TSX_FILE_PATTERN.test(filePath) - && !isTestLikePath(filePath) -} - -function isTestLikePath(filePath) { - return /(?:^|\/)__tests__\//.test(filePath) - || /(?:^|\/)__mocks__\//.test(filePath) - || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) - || /\.stories\.(?:ts|tsx)$/.test(filePath) - || /\.d\.ts$/.test(filePath) -} - -function getPathBaseNameWithoutExtension(filePath) { - if (!filePath) - return '' - - const normalizedPath = filePath.replace(/\\/g, '/') - const fileName = normalizedPath.split('/').pop() ?? '' - return fileName.replace(TS_TSX_FILE_PATTERN, '') -} - -function isGeneratedComponentFile(sourceCode) { - const leadingText = sourceCode.split('\n').slice(0, 5).join('\n') - return GENERATED_FILE_COMMENT_PATTERNS.some(pattern => pattern.test(leadingText)) -} - -function parseComponentFile(sourceCode) { - try { - return tsParser.parse(sourceCode, PARSER_OPTIONS) - } - catch { - return null - } -} - -function isPureBarrelComponentFile(ast) { - let hasRuntimeReExports = false - - for (const statement of ast.body) { - if (statement.type === 'ExportAllDeclaration') { - hasRuntimeReExports = true - continue - } - - if (statement.type === 'ExportNamedDeclaration' && statement.source) { - hasRuntimeReExports = hasRuntimeReExports || statement.exportKind !== 'type' - continue - } - - if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration') - continue - - return false - } - - return hasRuntimeReExports -} - -function isPureStaticComponentFile(ast) { - const importedStaticBindings = collectImportedStaticBindings(ast.body) - const staticBindings = new Set() - let hasRuntimeValue = false - - for (const statement of ast.body) { - if (statement.type === 'ImportDeclaration') - continue - - if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration') - continue - - if (statement.type === 'ExportAllDeclaration') - return false - - if (statement.type === 'ExportNamedDeclaration' && statement.source) - return false - - if (statement.type === 'ExportDefaultDeclaration') { - if (!isStaticExpression(statement.declaration, staticBindings, importedStaticBindings)) - return false - hasRuntimeValue = true - continue - } - - if (statement.type === 'ExportNamedDeclaration' && statement.declaration) { - if (!handleStaticDeclaration(statement.declaration, staticBindings, importedStaticBindings)) - return false - hasRuntimeValue = true - continue - } - - if (statement.type === 'ExportNamedDeclaration' && statement.specifiers.length > 0) { - const allStaticSpecifiers = statement.specifiers.every((specifier) => { - if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type') - return false - return specifier.local.type === 'Identifier' && staticBindings.has(specifier.local.name) - }) - if (!allStaticSpecifiers) - return false - hasRuntimeValue = true - continue - } - - if (!handleStaticDeclaration(statement, staticBindings, importedStaticBindings)) - return false - hasRuntimeValue = true - } - - return hasRuntimeValue -} - -function handleStaticDeclaration(statement, staticBindings, importedStaticBindings) { - if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') - return false - - for (const declarator of statement.declarations) { - if (declarator.id.type !== 'Identifier' || !declarator.init) - return false - - if (!isStaticExpression(declarator.init, staticBindings, importedStaticBindings)) - return false - - staticBindings.add(declarator.id.name) - } - - return true -} - -function collectImportedStaticBindings(statements) { - const importedBindings = new Set() - - for (const statement of statements) { - if (statement.type !== 'ImportDeclaration') - continue - - const importSource = String(statement.source.value ?? '') - const isTypeLikeSource = isTypeCoverageExcludedComponentFile(importSource) - const importIsStatic = statement.importKind === 'type' || isTypeLikeSource - if (!importIsStatic) - continue - - for (const specifier of statement.specifiers) { - if (specifier.local?.type === 'Identifier') - importedBindings.add(specifier.local.name) - } - } - - return importedBindings -} - -function isStaticExpression(node, staticBindings, importedStaticBindings) { - switch (node.type) { - case 'Literal': - return true - case 'Identifier': - return staticBindings.has(node.name) || importedStaticBindings.has(node.name) - case 'TemplateLiteral': - return node.expressions.every(expression => isStaticExpression(expression, staticBindings, importedStaticBindings)) - case 'ArrayExpression': - return node.elements.every(element => !element || isStaticExpression(element, staticBindings, importedStaticBindings)) - case 'ObjectExpression': - return node.properties.every((property) => { - if (property.type === 'SpreadElement') - return isStaticExpression(property.argument, staticBindings, importedStaticBindings) - - if (property.type !== 'Property' || property.method) - return false - - if (property.computed && !isStaticExpression(property.key, staticBindings, importedStaticBindings)) - return false - - if (property.shorthand) - return property.value.type === 'Identifier' && staticBindings.has(property.value.name) - - return isStaticExpression(property.value, staticBindings, importedStaticBindings) - }) - case 'UnaryExpression': - return isStaticExpression(node.argument, staticBindings, importedStaticBindings) - case 'BinaryExpression': - case 'LogicalExpression': - return isStaticExpression(node.left, staticBindings, importedStaticBindings) - && isStaticExpression(node.right, staticBindings, importedStaticBindings) - case 'ConditionalExpression': - return isStaticExpression(node.test, staticBindings, importedStaticBindings) - && isStaticExpression(node.consequent, staticBindings, importedStaticBindings) - && isStaticExpression(node.alternate, staticBindings, importedStaticBindings) - case 'MemberExpression': - return isStaticMemberExpression(node, staticBindings, importedStaticBindings) - case 'ChainExpression': - return isStaticExpression(node.expression, staticBindings, importedStaticBindings) - case 'TSAsExpression': - case 'TSSatisfiesExpression': - case 'TSTypeAssertion': - case 'TSNonNullExpression': - return isStaticExpression(node.expression, staticBindings, importedStaticBindings) - case 'ParenthesizedExpression': - return isStaticExpression(node.expression, staticBindings, importedStaticBindings) - default: - return false - } -} - -function isStaticMemberExpression(node, staticBindings, importedStaticBindings) { - if (!isStaticExpression(node.object, staticBindings, importedStaticBindings)) - return false - - if (!node.computed) - return node.property.type === 'Identifier' - - return isStaticExpression(node.property, staticBindings, importedStaticBindings) -} diff --git a/web/scripts/components-coverage-common.mjs b/web/scripts/components-coverage-common.mjs deleted file mode 100644 index e50da1d178..0000000000 --- a/web/scripts/components-coverage-common.mjs +++ /dev/null @@ -1,195 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import { getLineHits, normalizeToRepoRelative } from './check-components-diff-coverage-lib.mjs' -import { collectComponentCoverageExcludedFiles } from './component-coverage-filters.mjs' -import { EXCLUDED_COMPONENT_MODULES } from './components-coverage-thresholds.mjs' - -export const APP_COMPONENTS_ROOT = 'web/app/components' -export const APP_COMPONENTS_PREFIX = `${APP_COMPONENTS_ROOT}/` -export const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/' -export const SHARED_TEST_PREFIX = 'web/__tests__/' - -export function createComponentCoverageContext(repoRoot) { - const webRoot = path.join(repoRoot, 'web') - const excludedComponentCoverageFiles = new Set( - collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: APP_COMPONENTS_ROOT }), - ) - - return { - excludedComponentCoverageFiles, - repoRoot, - webRoot, - } -} - -export function loadTrackedCoverageEntries(coverage, context) { - const coverageEntries = new Map() - - for (const [file, entry] of Object.entries(coverage)) { - const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, { - appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX, - appComponentsPrefix: APP_COMPONENTS_PREFIX, - repoRoot: context.repoRoot, - sharedTestPrefix: SHARED_TEST_PREFIX, - webRoot: context.webRoot, - }) - - if (!isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles)) - continue - - coverageEntries.set(repoRelativePath, entry) - } - - return coverageEntries -} - -export function collectTrackedComponentSourceFiles(context) { - const trackedFiles = [] - - walkComponentSourceFiles(path.join(context.webRoot, 'app/components'), (absolutePath) => { - const repoRelativePath = path.relative(context.repoRoot, absolutePath).split(path.sep).join('/') - if (isTrackedComponentSourceFile(repoRelativePath, context.excludedComponentCoverageFiles)) - trackedFiles.push(repoRelativePath) - }) - - trackedFiles.sort((a, b) => a.localeCompare(b)) - return trackedFiles -} - -export function isTestLikePath(filePath) { - return /(?:^|\/)__tests__\//.test(filePath) - || /(?:^|\/)__mocks__\//.test(filePath) - || /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath) - || /\.stories\.(?:ts|tsx)$/.test(filePath) - || /\.d\.ts$/.test(filePath) -} - -export function getModuleName(filePath) { - const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length) - if (!relativePath) - return '(root)' - - const segments = relativePath.split('/') - return segments.length === 1 ? '(root)' : segments[0] -} - -export function isAnyComponentSourceFile(filePath) { - return filePath.startsWith(APP_COMPONENTS_PREFIX) - && /\.(?:ts|tsx)$/.test(filePath) - && !isTestLikePath(filePath) -} - -export function isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) { - return isAnyComponentSourceFile(filePath) - && ( - EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) - || excludedComponentCoverageFiles.has(filePath) - ) -} - -export function isTrackedComponentSourceFile(filePath, excludedComponentCoverageFiles) { - return isAnyComponentSourceFile(filePath) - && !isExcludedComponentSourceFile(filePath, excludedComponentCoverageFiles) -} - -export function isTrackedComponentTestFile(filePath) { - return filePath.startsWith(APP_COMPONENTS_PREFIX) - && isTestLikePath(filePath) - && !EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath)) -} - -export function isRelevantTestFile(filePath) { - return filePath.startsWith(SHARED_TEST_PREFIX) - || isTrackedComponentTestFile(filePath) -} - -export function isAnyWebTestFile(filePath) { - return filePath.startsWith('web/') - && isTestLikePath(filePath) -} - -export function getCoverageStats(entry) { - const lineHits = getLineHits(entry) - const statementHits = Object.values(entry.s ?? {}) - const functionHits = Object.values(entry.f ?? {}) - const branchHits = Object.values(entry.b ?? {}).flat() - - return { - lines: { - covered: Object.values(lineHits).filter(count => count > 0).length, - total: Object.keys(lineHits).length, - }, - statements: { - covered: statementHits.filter(count => count > 0).length, - total: statementHits.length, - }, - functions: { - covered: functionHits.filter(count => count > 0).length, - total: functionHits.length, - }, - branches: { - covered: branchHits.filter(count => count > 0).length, - total: branchHits.length, - }, - } -} - -export function sumCoverageStats(rows) { - const total = createEmptyCoverageStats() - for (const row of rows) - addCoverageStats(total, row) - return total -} - -export function mergeCoverageStats(map, moduleName, stats) { - const existing = map.get(moduleName) ?? createEmptyCoverageStats() - addCoverageStats(existing, stats) - map.set(moduleName, existing) -} - -export function percentage(covered, total) { - if (total === 0) - return 100 - return (covered / total) * 100 -} - -export function formatPercent(metric) { - return `${percentage(metric.covered, metric.total).toFixed(2)}%` -} - -function createEmptyCoverageStats() { - return { - lines: { covered: 0, total: 0 }, - statements: { covered: 0, total: 0 }, - functions: { covered: 0, total: 0 }, - branches: { covered: 0, total: 0 }, - } -} - -function addCoverageStats(target, source) { - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - target[metric].covered += source[metric].covered - target[metric].total += source[metric].total - } -} - -function walkComponentSourceFiles(currentDir, onFile) { - if (!fs.existsSync(currentDir)) - return - - const entries = fs.readdirSync(currentDir, { withFileTypes: true }) - for (const entry of entries) { - const entryPath = path.join(currentDir, entry.name) - if (entry.isDirectory()) { - if (entry.name === '__tests__' || entry.name === '__mocks__') - continue - walkComponentSourceFiles(entryPath, onFile) - continue - } - - if (!/\.(?:ts|tsx)$/.test(entry.name) || isTestLikePath(entry.name)) - continue - - onFile(entryPath) - } -} diff --git a/web/scripts/components-coverage-thresholds.mjs b/web/scripts/components-coverage-thresholds.mjs deleted file mode 100644 index fedd579947..0000000000 --- a/web/scripts/components-coverage-thresholds.mjs +++ /dev/null @@ -1,128 +0,0 @@ -// Floors were set from the app/components baseline captured on 2026-03-13, -// with a small buffer to avoid CI noise on existing code. -export const EXCLUDED_COMPONENT_MODULES = new Set([ - 'devtools', - 'provider', -]) - -export const COMPONENTS_GLOBAL_THRESHOLDS = { - lines: 58, - statements: 58, - functions: 58, - branches: 54, -} - -export const COMPONENT_MODULE_THRESHOLDS = { - 'app': { - lines: 45, - statements: 45, - functions: 50, - branches: 35, - }, - 'app-sidebar': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'apps': { - lines: 90, - statements: 90, - functions: 85, - branches: 80, - }, - 'base': { - lines: 95, - statements: 95, - functions: 90, - branches: 95, - }, - 'billing': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'custom': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'datasets': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'develop': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'explore': { - lines: 95, - statements: 95, - functions: 95, - branches: 85, - }, - 'goto-anything': { - lines: 90, - statements: 90, - functions: 90, - branches: 90, - }, - 'header': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'plugins': { - lines: 90, - statements: 90, - functions: 90, - branches: 85, - }, - 'rag-pipeline': { - lines: 95, - statements: 95, - functions: 95, - branches: 90, - }, - 'share': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'signin': { - lines: 95, - statements: 95, - functions: 95, - branches: 95, - }, - 'tools': { - lines: 95, - statements: 95, - functions: 90, - branches: 90, - }, - 'workflow': { - lines: 15, - statements: 15, - functions: 10, - branches: 10, - }, - 'workflow-app': { - lines: 20, - statements: 20, - functions: 25, - branches: 15, - }, -} - -export function getComponentModuleThreshold(moduleName) { - return COMPONENT_MODULE_THRESHOLDS[moduleName] ?? null -} diff --git a/web/scripts/report-components-coverage-baseline.mjs b/web/scripts/report-components-coverage-baseline.mjs deleted file mode 100644 index 16445b4689..0000000000 --- a/web/scripts/report-components-coverage-baseline.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import path from 'node:path' -import { COMPONENT_COVERAGE_EXCLUDE_LABEL } from './component-coverage-filters.mjs' -import { - collectTrackedComponentSourceFiles, - createComponentCoverageContext, - formatPercent, - getCoverageStats, - getModuleName, - loadTrackedCoverageEntries, - mergeCoverageStats, - percentage, - sumCoverageStats, -} from './components-coverage-common.mjs' -import { - COMPONENTS_GLOBAL_THRESHOLDS, - EXCLUDED_COMPONENT_MODULES, - getComponentModuleThreshold, -} from './components-coverage-thresholds.mjs' - -const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ') - -const repoRoot = repoRootFromCwd() -const context = createComponentCoverageContext(repoRoot) -const coverageFinalPath = path.join(context.webRoot, 'coverage', 'coverage-final.json') - -if (!fs.existsSync(coverageFinalPath)) { - console.error(`Coverage report not found at ${coverageFinalPath}`) - process.exit(1) -} - -const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8')) -const trackedSourceFiles = collectTrackedComponentSourceFiles(context) -const coverageEntries = loadTrackedCoverageEntries(coverage, context) -const fileCoverageRows = [] -const moduleCoverageMap = new Map() - -for (const [file, entry] of coverageEntries.entries()) { - const stats = getCoverageStats(entry) - const moduleName = getModuleName(file) - fileCoverageRows.push({ file, moduleName, ...stats }) - mergeCoverageStats(moduleCoverageMap, moduleName, stats) -} - -const overallCoverage = sumCoverageStats(fileCoverageRows) -const overallTargetGaps = getTargetGaps(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS) -const moduleCoverageRows = [...moduleCoverageMap.entries()] - .map(([moduleName, stats]) => ({ - moduleName, - stats, - targets: getComponentModuleThreshold(moduleName), - })) - .map(row => ({ - ...row, - targetGaps: row.targets ? getTargetGaps(row.stats, row.targets) : [], - })) - .sort((a, b) => { - const aWorst = Math.min(...a.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY) - const bWorst = Math.min(...b.targetGaps.map(gap => gap.delta), Number.POSITIVE_INFINITY) - return aWorst - bWorst || a.moduleName.localeCompare(b.moduleName) - }) - -appendSummary(buildSummary({ - coverageEntriesCount: coverageEntries.size, - moduleCoverageRows, - overallCoverage, - overallTargetGaps, - trackedSourceFilesCount: trackedSourceFiles.length, -})) - -function buildSummary({ - coverageEntriesCount, - moduleCoverageRows, - overallCoverage, - overallTargetGaps, - trackedSourceFilesCount, -}) { - const lines = [ - '### app/components Baseline Coverage', - '', - `Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``, - `Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``, - '', - `Coverage entries: ${coverageEntriesCount}/${trackedSourceFilesCount} tracked source files`, - '', - '| Metric | Current | Target | Delta |', - '|---|---:|---:|---:|', - `| Lines | ${formatPercent(overallCoverage.lines)} | ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% | ${formatDelta(overallCoverage.lines, COMPONENTS_GLOBAL_THRESHOLDS.lines)} |`, - `| Statements | ${formatPercent(overallCoverage.statements)} | ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% | ${formatDelta(overallCoverage.statements, COMPONENTS_GLOBAL_THRESHOLDS.statements)} |`, - `| Functions | ${formatPercent(overallCoverage.functions)} | ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% | ${formatDelta(overallCoverage.functions, COMPONENTS_GLOBAL_THRESHOLDS.functions)} |`, - `| Branches | ${formatPercent(overallCoverage.branches)} | ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% | ${formatDelta(overallCoverage.branches, COMPONENTS_GLOBAL_THRESHOLDS.branches)} |`, - '', - ] - - if (coverageEntriesCount !== trackedSourceFilesCount) { - lines.push('Warning: coverage report did not include every tracked component source file. CI should set `VITEST_COVERAGE_SCOPE=app-components` before collecting coverage.') - lines.push('') - } - - if (overallTargetGaps.length > 0) { - lines.push('Below baseline targets:') - for (const gap of overallTargetGaps) - lines.push(`- overall ${gap.metric}: ${gap.actual.toFixed(2)}% < ${gap.target}%`) - lines.push('') - } - - lines.push('
Module baseline coverage') - lines.push('') - lines.push('| Module | Lines | Statements | Functions | Branches | Targets | Status |') - lines.push('|---|---:|---:|---:|---:|---|---|') - for (const row of moduleCoverageRows) { - const targetsLabel = row.targets - ? `L${row.targets.lines}/S${row.targets.statements}/F${row.targets.functions}/B${row.targets.branches}` - : 'n/a' - const status = row.targets - ? (row.targetGaps.length > 0 ? 'below-target' : 'at-target') - : 'unconfigured' - lines.push(`| ${row.moduleName} | ${percentage(row.stats.lines.covered, row.stats.lines.total).toFixed(2)}% | ${percentage(row.stats.statements.covered, row.stats.statements.total).toFixed(2)}% | ${percentage(row.stats.functions.covered, row.stats.functions.total).toFixed(2)}% | ${percentage(row.stats.branches.covered, row.stats.branches.total).toFixed(2)}% | ${targetsLabel} | ${status} |`) - } - lines.push('
') - lines.push('') - lines.push('Report only: baseline targets no longer gate CI. The blocking rule is the pure diff coverage step.') - - return lines -} - -function getTargetGaps(stats, targets) { - const gaps = [] - for (const metric of ['lines', 'statements', 'functions', 'branches']) { - const actual = percentage(stats[metric].covered, stats[metric].total) - const target = targets[metric] - const delta = actual - target - if (delta < 0) { - gaps.push({ - actual, - delta, - metric, - target, - }) - } - } - return gaps -} - -function formatDelta(metric, target) { - const actual = percentage(metric.covered, metric.total) - const delta = actual - target - const sign = delta >= 0 ? '+' : '' - return `${sign}${delta.toFixed(2)}%` -} - -function appendSummary(lines) { - const content = `${lines.join('\n')}\n` - if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) - console.log(content) -} - -function repoRootFromCwd() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: process.cwd(), - encoding: 'utf8', - }).trim() -} diff --git a/web/scripts/report-components-test-touch.mjs b/web/scripts/report-components-test-touch.mjs deleted file mode 100644 index 43f316e39a..0000000000 --- a/web/scripts/report-components-test-touch.mjs +++ /dev/null @@ -1,168 +0,0 @@ -import { execFileSync } from 'node:child_process' -import fs from 'node:fs' -import { - buildGitDiffRevisionArgs, - normalizeDiffRangeMode, - resolveGitDiffContext, -} from './check-components-diff-coverage-lib.mjs' -import { - createComponentCoverageContext, - isAnyWebTestFile, - isRelevantTestFile, - isTrackedComponentSourceFile, -} from './components-coverage-common.mjs' - -const REQUESTED_DIFF_RANGE_MODE = normalizeDiffRangeMode(process.env.DIFF_RANGE_MODE) - -const repoRoot = repoRootFromCwd() -const context = createComponentCoverageContext(repoRoot) -const baseSha = process.env.BASE_SHA?.trim() -const headSha = process.env.HEAD_SHA?.trim() || 'HEAD' - -if (!baseSha || /^0+$/.test(baseSha)) { - appendSummary([ - '### app/components Test Touch', - '', - 'Skipped test-touch report because `BASE_SHA` was not available.', - ]) - process.exit(0) -} - -const diffContext = resolveGitDiffContext({ - base: baseSha, - head: headSha, - mode: REQUESTED_DIFF_RANGE_MODE, - execGit, -}) -const changedFiles = getChangedFiles(diffContext) -const changedSourceFiles = changedFiles.filter(filePath => isTrackedComponentSourceFile(filePath, context.excludedComponentCoverageFiles)) - -if (changedSourceFiles.length === 0) { - appendSummary([ - '### app/components Test Touch', - '', - ...buildDiffContextSummary(diffContext), - '', - 'No tracked source changes under `web/app/components/`. Test-touch report skipped.', - ]) - process.exit(0) -} - -const changedRelevantTestFiles = changedFiles.filter(isRelevantTestFile) -const changedOtherWebTestFiles = changedFiles.filter(filePath => isAnyWebTestFile(filePath) && !isRelevantTestFile(filePath)) -const totalChangedWebTests = [...new Set([...changedRelevantTestFiles, ...changedOtherWebTestFiles])] - -appendSummary(buildSummary({ - changedOtherWebTestFiles, - changedRelevantTestFiles, - diffContext, - changedSourceFiles, - totalChangedWebTests, -})) - -function buildSummary({ - changedOtherWebTestFiles, - changedRelevantTestFiles, - diffContext, - changedSourceFiles, - totalChangedWebTests, -}) { - const lines = [ - '### app/components Test Touch', - '', - ...buildDiffContextSummary(diffContext), - '', - `Tracked source files changed: ${changedSourceFiles.length}`, - `Component-local or shared integration tests changed: ${changedRelevantTestFiles.length}`, - `Other web tests changed: ${changedOtherWebTestFiles.length}`, - `Total changed web tests: ${totalChangedWebTests.length}`, - '', - ] - - if (totalChangedWebTests.length === 0) { - lines.push('Warning: no frontend test files changed alongside tracked component source changes.') - lines.push('') - } - - if (changedRelevantTestFiles.length > 0) { - lines.push('
Changed component-local or shared tests') - lines.push('') - for (const filePath of changedRelevantTestFiles.slice(0, 40)) - lines.push(`- ${filePath.replace('web/', '')}`) - if (changedRelevantTestFiles.length > 40) - lines.push(`- ... ${changedRelevantTestFiles.length - 40} more`) - lines.push('
') - lines.push('') - } - - if (changedOtherWebTestFiles.length > 0) { - lines.push('
Changed other web tests') - lines.push('') - for (const filePath of changedOtherWebTestFiles.slice(0, 40)) - lines.push(`- ${filePath.replace('web/', '')}`) - if (changedOtherWebTestFiles.length > 40) - lines.push(`- ... ${changedOtherWebTestFiles.length - 40} more`) - lines.push('
') - lines.push('') - } - - lines.push('Report only: test-touch is now advisory and no longer blocks the diff coverage gate.') - return lines -} - -function buildDiffContextSummary(diffContext) { - const lines = [ - `Compared \`${diffContext.base.slice(0, 12)}\` -> \`${diffContext.head.slice(0, 12)}\``, - ] - - if (diffContext.useCombinedMergeDiff) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff strategy: \`combined-merge\` (${diffContext.reason})`) - } - else if (diffContext.reason) { - lines.push(`Requested diff range mode: \`${diffContext.requestedMode}\``) - lines.push(`Effective diff range mode: \`${diffContext.mode}\` (${diffContext.reason})`) - } - else { - lines.push(`Diff range mode: \`${diffContext.mode}\``) - } - - return lines -} - -function getChangedFiles(diffContext) { - if (diffContext.useCombinedMergeDiff) { - const output = execGit(['diff-tree', '--cc', '--no-commit-id', '--name-only', '-r', diffContext.head, '--', 'web']) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - } - - const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', ...buildGitDiffRevisionArgs(diffContext.base, diffContext.head, diffContext.mode), '--', 'web']) - return output - .split('\n') - .map(line => line.trim()) - .filter(Boolean) -} - -function appendSummary(lines) { - const content = `${lines.join('\n')}\n` - if (process.env.GITHUB_STEP_SUMMARY) - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content) - console.log(content) -} - -function execGit(args) { - return execFileSync('git', args, { - cwd: repoRoot, - encoding: 'utf8', - }) -} - -function repoRootFromCwd() { - return execFileSync('git', ['rev-parse', '--show-toplevel'], { - cwd: process.cwd(), - encoding: 'utf8', - }).trim() -} diff --git a/web/service/fetch.spec.ts b/web/service/fetch.spec.ts index ef38a4c510..0c01d32438 100644 --- a/web/service/fetch.spec.ts +++ b/web/service/fetch.spec.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { base } from './fetch' -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: vi.fn(), }, })) diff --git a/web/service/fetch.ts b/web/service/fetch.ts index d5934d4a57..664280cd8e 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -2,7 +2,7 @@ import type { AfterResponseHook, BeforeRequestHook, Hooks } from 'ky' import type { IOtherOptions } from './base' import Cookies from 'js-cookie' import ky, { HTTPError } from 'ky' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { API_PREFIX, APP_VERSION, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_MARKETPLACE, MARKETPLACE_API_PREFIX, PASSPORT_HEADER_NAME, PUBLIC_API_PREFIX, WEB_APP_SHARE_CODE_HEADER_NAME } from '@/config' import { getWebAppAccessToken, getWebAppPassport } from './webapp-auth' @@ -48,7 +48,7 @@ const afterResponseErrorCode = (otherOptions: IOtherOptions): AfterResponseHook const shouldNotifyError = response.status !== 401 && errorData && !otherOptions.silent if (shouldNotifyError) - Toast.notify({ type: 'error', message: errorData.message }) + toast.add({ type: 'error', title: errorData.message }) if (response.status === 403 && errorData?.code === 'already_setup') globalThis.location.href = `${globalThis.location.origin}/signin` diff --git a/web/tailwind.config.js b/web/tailwind.config.js index dfba1be5e9..db0a172e1c 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -8,6 +8,7 @@ const config = { './context/**/*.{js,ts,jsx,tsx}', './node_modules/streamdown/dist/*.js', './node_modules/@streamdown/math/dist/*.js', + '!./**/*.{spec,test}.{js,ts,jsx,tsx}', ], ...commonConfig, } diff --git a/web/utils/semver.spec.ts b/web/utils/semver.spec.ts index c2188a976c..42d6a3fb54 100644 --- a/web/utils/semver.spec.ts +++ b/web/utils/semver.spec.ts @@ -1,4 +1,4 @@ -import { compareVersion, getLatestVersion, isEqualOrLaterThanVersion } from './semver' +import { compareVersion, getLatestVersion, isEarlierThanVersion, isEqualOrLaterThanVersion } from './semver' describe('semver utilities', () => { describe('getLatestVersion', () => { @@ -72,4 +72,24 @@ describe('semver utilities', () => { expect(isEqualOrLaterThanVersion('1.0.0-alpha', '1.0.0')).toBe(false) }) }) + + describe('isEarlierThanVersion', () => { + it('should return true when baseVersion is less than targetVersion', () => { + expect(isEarlierThanVersion('1.0.0', '1.1.0')).toBe(true) + expect(isEarlierThanVersion('1.9.9', '2.0.0')).toBe(true) + expect(isEarlierThanVersion('1.0.0', '1.0.1')).toBe(true) + }) + + it('should return false when baseVersion is equal to or greater than targetVersion', () => { + expect(isEarlierThanVersion('1.0.0', '1.0.0')).toBe(false) + expect(isEarlierThanVersion('1.1.0', '1.0.0')).toBe(false) + expect(isEarlierThanVersion('1.0.1', '1.0.0')).toBe(false) + }) + + it('should handle pre-release versions correctly', () => { + expect(isEarlierThanVersion('1.0.0-beta', '1.0.0')).toBe(true) + expect(isEarlierThanVersion('1.0.0-alpha', '1.0.0-beta')).toBe(true) + expect(isEarlierThanVersion('1.0.0', '1.0.0-beta')).toBe(false) + }) + }) }) diff --git a/web/utils/semver.ts b/web/utils/semver.ts index aea84153ec..a22d219947 100644 --- a/web/utils/semver.ts +++ b/web/utils/semver.ts @@ -1,13 +1,19 @@ -import semver from 'semver' +import { compare, greaterOrEqual, lessThan, parse } from 'std-semver' export const getLatestVersion = (versionList: string[]) => { - return semver.rsort(versionList)[0] + return [...versionList].sort((versionA, versionB) => { + return compare(parse(versionB), parse(versionA)) + })[0] } export const compareVersion = (v1: string, v2: string) => { - return semver.compare(v1, v2) + return compare(parse(v1), parse(v2)) } export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => { - return semver.gte(baseVersion, targetVersion) + return greaterOrEqual(parse(baseVersion), parse(targetVersion)) +} + +export const isEarlierThanVersion = (baseVersion: string, targetVersion: string) => { + return lessThan(parse(baseVersion), parse(targetVersion)) } diff --git a/web/vite.config.ts b/web/vite.config.ts index de74154651..665d2d0a5f 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,24 +7,15 @@ import { defineConfig } from 'vite-plus' import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector' import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr' import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test' -import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs' -import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs' const projectRoot = path.dirname(fileURLToPath(import.meta.url)) const isCI = !!process.env.CI -const coverageScope = process.env.VITEST_COVERAGE_SCOPE const browserInitializerInjectTarget = path.resolve(projectRoot, 'app/components/browser-initializer.tsx') -const excludedAppComponentsCoveragePaths = [...EXCLUDED_COMPONENT_MODULES] - .map(moduleName => `app/components/${moduleName}/**`) export default defineConfig(({ mode }) => { const isTest = mode === 'test' const isStorybook = process.env.STORYBOOK === 'true' || process.argv.some(arg => arg.toLowerCase().includes('storybook')) - const isAppComponentsCoverage = coverageScope === 'app-components' - const excludedComponentCoverageFiles = isAppComponentsCoverage - ? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' }) - : [] return { plugins: isTest @@ -87,25 +78,9 @@ export default defineConfig(({ mode }) => { environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], - reporters: ['agent'], coverage: { provider: 'v8', reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'], - ...(isAppComponentsCoverage - ? { - include: ['app/components/**/*.{ts,tsx}'], - exclude: [ - 'app/components/**/*.d.ts', - 'app/components/**/*.spec.{ts,tsx}', - 'app/components/**/*.test.{ts,tsx}', - 'app/components/**/__tests__/**', - 'app/components/**/__mocks__/**', - 'app/components/**/*.stories.{ts,tsx}', - ...excludedComponentCoverageFiles, - ...excludedAppComponentsCoveragePaths, - ], - } - : {}), }, }, }