mirror of
https://github.com/langgenius/dify.git
synced 2026-03-03 22:15:09 +00:00
Compare commits
8 Commits
refactor/b
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc15aff42c | ||
|
|
664ab123c3 | ||
|
|
d7e399872d | ||
|
|
65bf632ec0 | ||
|
|
1a90c4d81b | ||
|
|
3a8ff301fc | ||
|
|
7f67e1a2fc | ||
|
|
5e79d35881 |
@@ -204,6 +204,16 @@ When assigned to test a directory/path, test **ALL content** within that path:
|
||||
|
||||
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
|
||||
|
||||
### `nuqs` Query State Testing (Required for URL State Hooks)
|
||||
|
||||
When a component or hook uses `useQueryState` / `useQueryStates`:
|
||||
|
||||
- ✅ Use `NuqsTestingAdapter` (prefer shared helpers in `web/test/nuqs-testing.tsx`)
|
||||
- ✅ Assert URL synchronization via `onUrlUpdate` (`searchParams`, `options.history`)
|
||||
- ✅ For custom parsers (`createParser`), keep `parse` and `serialize` bijective and add round-trip edge cases (`%2F`, `%25`, spaces, legacy encoded values)
|
||||
- ✅ Verify default-clearing behavior (default values should be removed from URL when applicable)
|
||||
- ⚠️ Only mock `nuqs` directly when URL behavior is explicitly out of scope for the test
|
||||
|
||||
## Core Principles
|
||||
|
||||
### 1. AAA Pattern (Arrange-Act-Assert)
|
||||
|
||||
@@ -80,6 +80,9 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
|
||||
- [ ] Router mocks match actual Next.js API
|
||||
- [ ] Mocks reflect actual component conditional behavior
|
||||
- [ ] Only mock: API services, complex context providers, third-party libs
|
||||
- [ ] For `nuqs` URL-state tests, wrap with `NuqsTestingAdapter` (prefer `web/test/nuqs-testing.tsx`)
|
||||
- [ ] For `nuqs` URL-state tests, assert `onUrlUpdate` payload (`searchParams`, `options.history`)
|
||||
- [ ] If custom `nuqs` parser exists, add round-trip tests for encoded edge cases (`%2F`, `%25`, spaces, legacy encoded values)
|
||||
|
||||
### Queries
|
||||
|
||||
|
||||
@@ -125,6 +125,31 @@ describe('Component', () => {
|
||||
})
|
||||
```
|
||||
|
||||
### 2.1 `nuqs` Query State (Preferred: Testing Adapter)
|
||||
|
||||
For tests that validate URL query behavior, use `NuqsTestingAdapter` instead of mocking `nuqs` directly.
|
||||
|
||||
```typescript
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
|
||||
it('should sync query to URL with push history', async () => {
|
||||
const { result, onUrlUpdate } = renderHookWithNuqs(() => useMyQueryState(), {
|
||||
searchParams: '?page=1',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
```
|
||||
|
||||
Use direct `vi.mock('nuqs')` only when URL synchronization is intentionally out of scope.
|
||||
|
||||
### 3. Portal Components (with Shared State)
|
||||
|
||||
```typescript
|
||||
|
||||
26
.github/dependabot.yml
vendored
26
.github/dependabot.yml
vendored
@@ -1,25 +1,37 @@
|
||||
version: 2
|
||||
|
||||
multi-ecosystem-groups:
|
||||
python:
|
||||
schedule:
|
||||
interval: "weekly" # or whatever schedule you want
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/api"
|
||||
open-pull-requests-limit: 2
|
||||
patterns: ["*"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
python-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/api"
|
||||
open-pull-requests-limit: 2
|
||||
patterns: ["*"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
uv-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 2
|
||||
groups:
|
||||
storybook:
|
||||
patterns:
|
||||
- "storybook"
|
||||
- "@storybook/*"
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
exclude-patterns:
|
||||
- "storybook"
|
||||
- "@storybook/*"
|
||||
|
||||
2
.github/workflows/style.yml
vendored
2
.github/workflows/style.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
||||
uses: actions/setup-node@v6
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: ''
|
||||
cache-dependency-path: 'pnpm-lock.yaml'
|
||||
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
|
||||
6
.github/workflows/web-tests.yml
vendored
6
.github/workflows/web-tests.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
@@ -457,7 +457,7 @@ jobs:
|
||||
uses: actions/setup-node@v6
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: ./web/pnpm-lock.yaml
|
||||
|
||||
|
||||
@@ -5,89 +5,89 @@ requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
"aliyun-log-python-sdk~=0.9.37",
|
||||
"arize-phoenix-otel~=0.9.2",
|
||||
"azure-identity==1.16.1",
|
||||
"beautifulsoup4==4.12.2",
|
||||
"boto3==1.35.99",
|
||||
"arize-phoenix-otel~=0.15.0",
|
||||
"azure-identity==1.25.2",
|
||||
"beautifulsoup4==4.14.3",
|
||||
"boto3==1.42.59",
|
||||
"bs4~=0.0.1",
|
||||
"cachetools~=5.3.0",
|
||||
"celery~=5.5.2",
|
||||
"cachetools~=7.0.2",
|
||||
"celery~=5.6.2",
|
||||
"charset-normalizer>=3.4.4",
|
||||
"flask~=3.1.2",
|
||||
"flask-compress>=1.17,<1.18",
|
||||
"flask-compress>=1.17,<1.24",
|
||||
"flask-cors~=6.0.0",
|
||||
"flask-login~=0.6.3",
|
||||
"flask-migrate~=4.0.7",
|
||||
"flask-migrate~=4.1.0",
|
||||
"flask-orjson~=2.0.0",
|
||||
"flask-sqlalchemy~=3.1.1",
|
||||
"gevent~=25.9.1",
|
||||
"gmpy2~=2.3.0",
|
||||
"google-api-core>=2.19.1",
|
||||
"google-api-python-client==2.189.0",
|
||||
"google-api-python-client==2.191.0",
|
||||
"google-auth>=2.47.0",
|
||||
"google-auth-httplib2==0.2.0",
|
||||
"google-auth-httplib2==0.3.0",
|
||||
"google-cloud-aiplatform>=1.123.0",
|
||||
"googleapis-common-protos>=1.65.0",
|
||||
"gunicorn~=23.0.0",
|
||||
"gunicorn~=25.1.0",
|
||||
"httpx[socks]~=0.28.0",
|
||||
"jieba==0.42.1",
|
||||
"json-repair>=0.55.1",
|
||||
"jsonschema>=4.25.1",
|
||||
"langfuse~=2.51.3",
|
||||
"langsmith~=0.1.77",
|
||||
"markdown~=3.5.1",
|
||||
"langfuse~=3.14.5",
|
||||
"langsmith~=0.7.10",
|
||||
"markdown~=3.10.2",
|
||||
"mlflow-skinny>=3.0.0",
|
||||
"numpy~=1.26.4",
|
||||
"numpy~=2.4.2",
|
||||
"openpyxl~=3.1.5",
|
||||
"opik~=1.8.72",
|
||||
"litellm==1.77.1", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.28.0",
|
||||
"opentelemetry-distro==0.49b0",
|
||||
"opentelemetry-exporter-otlp==1.28.0",
|
||||
"opentelemetry-exporter-otlp-proto-common==1.28.0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc==1.28.0",
|
||||
"opentelemetry-exporter-otlp-proto-http==1.28.0",
|
||||
"opentelemetry-instrumentation==0.49b0",
|
||||
"opentelemetry-instrumentation-celery==0.49b0",
|
||||
"opentelemetry-instrumentation-flask==0.49b0",
|
||||
"opentelemetry-instrumentation-httpx==0.49b0",
|
||||
"opentelemetry-instrumentation-redis==0.49b0",
|
||||
"opentelemetry-instrumentation-sqlalchemy==0.49b0",
|
||||
"opentelemetry-propagator-b3==1.28.0",
|
||||
"opentelemetry-proto==1.28.0",
|
||||
"opentelemetry-sdk==1.28.0",
|
||||
"opentelemetry-semantic-conventions==0.49b0",
|
||||
"opentelemetry-util-http==0.49b0",
|
||||
"pandas[excel,output-formatting,performance]~=2.2.2",
|
||||
"opik~=1.10.25",
|
||||
"litellm==1.82.0", # Pinned to avoid madoka dependency issue
|
||||
"opentelemetry-api==1.39.1",
|
||||
"opentelemetry-distro==0.60b1",
|
||||
"opentelemetry-exporter-otlp==1.39.1",
|
||||
"opentelemetry-exporter-otlp-proto-common==1.39.1",
|
||||
"opentelemetry-exporter-otlp-proto-grpc==1.39.1",
|
||||
"opentelemetry-exporter-otlp-proto-http==1.39.1",
|
||||
"opentelemetry-instrumentation==0.60b1",
|
||||
"opentelemetry-instrumentation-celery==0.60b1",
|
||||
"opentelemetry-instrumentation-flask==0.60b1",
|
||||
"opentelemetry-instrumentation-httpx==0.60b1",
|
||||
"opentelemetry-instrumentation-redis==0.60b1",
|
||||
"opentelemetry-instrumentation-sqlalchemy==0.60b1",
|
||||
"opentelemetry-propagator-b3==1.39.1",
|
||||
"opentelemetry-proto==1.39.1",
|
||||
"opentelemetry-sdk==1.39.1",
|
||||
"opentelemetry-semantic-conventions==0.60b1",
|
||||
"opentelemetry-util-http==0.60b1",
|
||||
"pandas[excel,output-formatting,performance]~=3.0.1",
|
||||
"psycogreen~=1.0.2",
|
||||
"psycopg2-binary~=2.9.6",
|
||||
"pycryptodome==3.23.0",
|
||||
"pydantic~=2.12.5",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.12.0",
|
||||
"pydantic-extra-types~=2.11.0",
|
||||
"pydantic-settings~=2.13.1",
|
||||
"pyjwt~=2.11.0",
|
||||
"pypdfium2==5.2.0",
|
||||
"pypdfium2==5.5.0",
|
||||
"python-docx~=1.2.0",
|
||||
"python-dotenv==1.0.1",
|
||||
"python-dotenv==1.2.2",
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=7.2.0",
|
||||
"resend~=2.9.0",
|
||||
"sentry-sdk[flask]~=2.28.0",
|
||||
"resend~=2.23.0",
|
||||
"sentry-sdk[flask]~=2.54.0",
|
||||
"sqlalchemy~=2.0.29",
|
||||
"starlette==0.49.1",
|
||||
"tiktoken~=0.9.0",
|
||||
"transformers~=4.56.1",
|
||||
"unstructured[docx,epub,md,ppt,pptx]~=0.18.18",
|
||||
"yarl~=1.18.3",
|
||||
"starlette==0.52.1",
|
||||
"tiktoken~=0.12.0",
|
||||
"transformers~=5.2.0",
|
||||
"unstructured[docx,epub,md,ppt,pptx]~=0.21.5",
|
||||
"yarl~=1.23.0",
|
||||
"webvtt-py~=0.5.1",
|
||||
"sseclient-py~=1.8.0",
|
||||
"sseclient-py~=1.9.0",
|
||||
"httpx-sse~=0.4.0",
|
||||
"sendgrid~=6.12.3",
|
||||
"flask-restx~=1.3.2",
|
||||
"packaging~=23.2",
|
||||
"packaging~=26.0",
|
||||
"croniter>=6.0.0",
|
||||
"weaviate-client==4.17.0",
|
||||
"weaviate-client==4.20.1",
|
||||
"apscheduler>=3.11.0",
|
||||
"weave>=0.52.16",
|
||||
"fastopenapi[flask]>=0.7.0",
|
||||
@@ -109,46 +109,46 @@ package = false
|
||||
# Required for development and running tests
|
||||
############################################################
|
||||
dev = [
|
||||
"coverage~=7.2.4",
|
||||
"dotenv-linter~=0.5.0",
|
||||
"faker~=38.2.0",
|
||||
"coverage~=7.13.4",
|
||||
"dotenv-linter~=0.7.0",
|
||||
"faker~=40.5.1",
|
||||
"lxml-stubs~=0.5.1",
|
||||
"basedpyright~=1.31.0",
|
||||
"ruff~=0.14.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-benchmark~=4.0.0",
|
||||
"pytest-cov~=4.1.0",
|
||||
"pytest-env~=1.1.3",
|
||||
"pytest-mock~=3.14.0",
|
||||
"testcontainers~=4.13.2",
|
||||
"basedpyright~=1.38.2",
|
||||
"ruff~=0.15.4",
|
||||
"pytest~=9.0.2",
|
||||
"pytest-benchmark~=5.2.3",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-env~=1.5.0",
|
||||
"pytest-mock~=3.15.1",
|
||||
"testcontainers~=4.14.1",
|
||||
"types-aiofiles~=25.1.0",
|
||||
"types-beautifulsoup4~=4.12.0",
|
||||
"types-cachetools~=5.5.0",
|
||||
"types-cachetools~=6.2.0",
|
||||
"types-colorama~=0.4.15",
|
||||
"types-defusedxml~=0.7.0",
|
||||
"types-deprecated~=1.2.15",
|
||||
"types-docutils~=0.21.0",
|
||||
"types-jsonschema~=4.23.0",
|
||||
"types-flask-cors~=5.0.0",
|
||||
"types-deprecated~=1.3.1",
|
||||
"types-docutils~=0.22.3",
|
||||
"types-jsonschema~=4.26.0",
|
||||
"types-flask-cors~=6.0.0",
|
||||
"types-flask-migrate~=4.1.0",
|
||||
"types-gevent~=25.9.0",
|
||||
"types-greenlet~=3.3.0",
|
||||
"types-html5lib~=1.1.11",
|
||||
"types-markdown~=3.10.2",
|
||||
"types-oauthlib~=3.2.0",
|
||||
"types-oauthlib~=3.3.0",
|
||||
"types-objgraph~=3.6.0",
|
||||
"types-olefile~=0.47.0",
|
||||
"types-openpyxl~=3.1.5",
|
||||
"types-pexpect~=4.9.0",
|
||||
"types-protobuf~=5.29.1",
|
||||
"types-protobuf~=6.32.1",
|
||||
"types-psutil~=7.2.2",
|
||||
"types-psycopg2~=2.9.21",
|
||||
"types-pygments~=2.19.0",
|
||||
"types-pymysql~=1.1.0",
|
||||
"types-python-dateutil~=2.9.0",
|
||||
"types-pywin32~=310.0.0",
|
||||
"types-pywin32~=311.0.0",
|
||||
"types-pyyaml~=6.0.12",
|
||||
"types-regex~=2024.11.6",
|
||||
"types-regex~=2026.2.28",
|
||||
"types-shapely~=2.1.0",
|
||||
"types-simplejson>=3.20.0",
|
||||
"types-six>=1.17.0",
|
||||
@@ -161,13 +161,13 @@ dev = [
|
||||
"types_pyOpenSSL>=24.1.0",
|
||||
"types_cffi>=1.17.0",
|
||||
"types_setuptools>=80.9.0",
|
||||
"pandas-stubs~=2.2.3",
|
||||
"pandas-stubs~=3.0.0",
|
||||
"scipy-stubs>=1.15.3.0",
|
||||
"types-python-http-client>=3.3.7.20240910",
|
||||
"import-linter>=2.3",
|
||||
"types-redis>=4.6.0.20241004",
|
||||
"celery-types>=0.23.0",
|
||||
"mypy~=1.17.1",
|
||||
"mypy~=1.19.1",
|
||||
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
|
||||
"sseclient-py>=1.8.0",
|
||||
"pytest-timeout>=2.4.0",
|
||||
@@ -180,14 +180,14 @@ dev = [
|
||||
# Required for storage clients
|
||||
############################################################
|
||||
storage = [
|
||||
"azure-storage-blob==12.26.0",
|
||||
"azure-storage-blob==12.28.0",
|
||||
"bce-python-sdk~=0.9.23",
|
||||
"cos-python-sdk-v5==1.9.38",
|
||||
"cos-python-sdk-v5==1.9.41",
|
||||
"esdk-obs-python==3.25.8",
|
||||
"google-cloud-storage>=3.0.0",
|
||||
"opendal~=0.46.0",
|
||||
"oss2==2.18.5",
|
||||
"supabase~=2.18.1",
|
||||
"oss2==2.19.1",
|
||||
"supabase~=2.28.0",
|
||||
"tos~=2.9.0",
|
||||
]
|
||||
|
||||
@@ -201,29 +201,29 @@ tools = ["cloudscraper~=1.2.71", "nltk~=3.9.1"]
|
||||
# Required by vector store clients
|
||||
############################################################
|
||||
vdb = [
|
||||
"alibabacloud_gpdb20160503~=3.8.0",
|
||||
"alibabacloud_tea_openapi~=0.3.9",
|
||||
"chromadb==0.5.20",
|
||||
"clickhouse-connect~=0.10.0",
|
||||
"alibabacloud_gpdb20160503~=5.0.2",
|
||||
"alibabacloud_tea_openapi~=0.4.3",
|
||||
"chromadb==1.5.2",
|
||||
"clickhouse-connect~=0.13.0",
|
||||
"clickzetta-connector-python>=0.8.102",
|
||||
"couchbase~=4.3.0",
|
||||
"elasticsearch==8.14.0",
|
||||
"couchbase~=4.5.0",
|
||||
"elasticsearch==9.3.0",
|
||||
"opensearch-py==3.1.0",
|
||||
"oracledb==3.3.0",
|
||||
"oracledb==3.4.2",
|
||||
"pgvecto-rs[sqlalchemy]~=0.2.1",
|
||||
"pgvector==0.2.5",
|
||||
"pymilvus~=2.5.0",
|
||||
"pymochow==2.2.9",
|
||||
"pgvector==0.4.2",
|
||||
"pymilvus~=2.6.9",
|
||||
"pymochow==2.3.6",
|
||||
"pyobvector~=0.2.17",
|
||||
"qdrant-client==1.9.0",
|
||||
"qdrant-client==1.17.0",
|
||||
"intersystems-irispython>=5.1.0",
|
||||
"tablestore==6.3.7",
|
||||
"tcvectordb~=1.6.4",
|
||||
"tidb-vector==0.0.9",
|
||||
"upstash-vector==0.6.0",
|
||||
"tablestore==6.4.1",
|
||||
"tcvectordb~=2.0.0",
|
||||
"tidb-vector==0.0.15",
|
||||
"upstash-vector==0.8.0",
|
||||
"volcengine-compat~=1.0.0",
|
||||
"weaviate-client==4.17.0",
|
||||
"xinference-client~=1.2.2",
|
||||
"weaviate-client==4.20.1",
|
||||
"xinference-client~=2.2.0",
|
||||
"mo-vector~=0.1.13",
|
||||
"mysql-connector-python>=9.3.0",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
"""Integration tests for DocumentService.batch_update_document_status.
|
||||
|
||||
This suite validates SQL-backed batch status updates with testcontainers.
|
||||
It keeps database access real and only patches non-DB side effects.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import Dataset, Document
|
||||
from services.dataset_service import DocumentService
|
||||
from services.errors.document import DocumentIndexingError
|
||||
|
||||
FIXED_TIME = datetime.datetime(2023, 1, 1, 12, 0, 0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserDouble:
|
||||
"""Minimal user object for batch update operations."""
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
class DocumentBatchUpdateIntegrationDataFactory:
|
||||
"""Factory for creating persisted entities used in integration tests."""
|
||||
|
||||
@staticmethod
|
||||
def create_dataset(
|
||||
dataset_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
name: str = "Test Dataset",
|
||||
created_by: str | None = None,
|
||||
) -> Dataset:
|
||||
"""Create and persist a dataset."""
|
||||
dataset = Dataset(
|
||||
tenant_id=tenant_id or str(uuid4()),
|
||||
name=name,
|
||||
data_source_type="upload_file",
|
||||
created_by=created_by or str(uuid4()),
|
||||
)
|
||||
if dataset_id:
|
||||
dataset.id = dataset_id
|
||||
|
||||
db.session.add(dataset)
|
||||
db.session.commit()
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def create_document(
|
||||
dataset: Dataset,
|
||||
document_id: str | None = None,
|
||||
name: str = "test_document.pdf",
|
||||
enabled: bool = True,
|
||||
archived: bool = False,
|
||||
indexing_status: str = "completed",
|
||||
completed_at: datetime.datetime | None = None,
|
||||
position: int = 1,
|
||||
created_by: str | None = None,
|
||||
commit: bool = True,
|
||||
**kwargs,
|
||||
) -> Document:
|
||||
"""Create a document bound to the given dataset and persist it."""
|
||||
document = Document(
|
||||
tenant_id=dataset.tenant_id,
|
||||
dataset_id=dataset.id,
|
||||
position=position,
|
||||
data_source_type="upload_file",
|
||||
data_source_info=json.dumps({"upload_file_id": str(uuid4())}),
|
||||
batch=f"batch-{uuid4()}",
|
||||
name=name,
|
||||
created_from="web",
|
||||
created_by=created_by or str(uuid4()),
|
||||
doc_form="text_model",
|
||||
)
|
||||
document.id = document_id or str(uuid4())
|
||||
document.enabled = enabled
|
||||
document.archived = archived
|
||||
document.indexing_status = indexing_status
|
||||
document.completed_at = (
|
||||
completed_at if completed_at is not None else (FIXED_TIME if indexing_status == "completed" else None)
|
||||
)
|
||||
|
||||
for key, value in kwargs.items():
|
||||
setattr(document, key, value)
|
||||
|
||||
db.session.add(document)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
def create_multiple_documents(
|
||||
dataset: Dataset,
|
||||
document_ids: list[str],
|
||||
enabled: bool = True,
|
||||
archived: bool = False,
|
||||
indexing_status: str = "completed",
|
||||
) -> list[Document]:
|
||||
"""Create and persist multiple documents for one dataset in a single transaction."""
|
||||
documents: list[Document] = []
|
||||
for index, doc_id in enumerate(document_ids, start=1):
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
document_id=doc_id,
|
||||
name=f"document_{doc_id}.pdf",
|
||||
enabled=enabled,
|
||||
archived=archived,
|
||||
indexing_status=indexing_status,
|
||||
position=index,
|
||||
commit=False,
|
||||
)
|
||||
documents.append(document)
|
||||
db.session.commit()
|
||||
return documents
|
||||
|
||||
@staticmethod
|
||||
def create_user(user_id: str | None = None) -> UserDouble:
|
||||
"""Create a lightweight user for update metadata fields."""
|
||||
return UserDouble(id=user_id or str(uuid4()))
|
||||
|
||||
|
||||
class TestDatasetServiceBatchUpdateDocumentStatus:
|
||||
"""Integration coverage for batch document status updates."""
|
||||
|
||||
@pytest.fixture
|
||||
def patched_dependencies(self):
|
||||
"""Patch non-DB collaborators only."""
|
||||
with (
|
||||
patch("services.dataset_service.redis_client") as redis_client,
|
||||
patch("services.dataset_service.add_document_to_index_task") as add_task,
|
||||
patch("services.dataset_service.remove_document_from_index_task") as remove_task,
|
||||
patch("services.dataset_service.naive_utc_now") as naive_utc_now,
|
||||
):
|
||||
naive_utc_now.return_value = FIXED_TIME
|
||||
redis_client.get.return_value = None
|
||||
yield {
|
||||
"redis_client": redis_client,
|
||||
"add_task": add_task,
|
||||
"remove_task": remove_task,
|
||||
"naive_utc_now": naive_utc_now,
|
||||
}
|
||||
|
||||
def _assert_document_enabled(self, document: Document, current_time: datetime.datetime):
|
||||
"""Verify enabled-state fields after action=enable."""
|
||||
assert document.enabled is True
|
||||
assert document.disabled_at is None
|
||||
assert document.disabled_by is None
|
||||
assert document.updated_at == current_time
|
||||
|
||||
def _assert_document_disabled(self, document: Document, user_id: str, current_time: datetime.datetime):
|
||||
"""Verify disabled-state fields after action=disable."""
|
||||
assert document.enabled is False
|
||||
assert document.disabled_at == current_time
|
||||
assert document.disabled_by == user_id
|
||||
assert document.updated_at == current_time
|
||||
|
||||
def _assert_document_archived(self, document: Document, user_id: str, current_time: datetime.datetime):
|
||||
"""Verify archived-state fields after action=archive."""
|
||||
assert document.archived is True
|
||||
assert document.archived_at == current_time
|
||||
assert document.archived_by == user_id
|
||||
assert document.updated_at == current_time
|
||||
|
||||
def _assert_document_unarchived(self, document: Document):
|
||||
"""Verify unarchived-state fields after action=un_archive."""
|
||||
assert document.archived is False
|
||||
assert document.archived_at is None
|
||||
assert document.archived_by is None
|
||||
|
||||
def test_batch_update_enable_documents_success(self, db_session_with_containers, patched_dependencies):
|
||||
"""Enable disabled documents and trigger indexing side effects."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document_ids = [str(uuid4()), str(uuid4())]
|
||||
disabled_docs = DocumentBatchUpdateIntegrationDataFactory.create_multiple_documents(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=document_ids, action="enable", user=user
|
||||
)
|
||||
|
||||
# Assert
|
||||
for document in disabled_docs:
|
||||
db.session.refresh(document)
|
||||
self._assert_document_enabled(document, FIXED_TIME)
|
||||
|
||||
expected_get_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
|
||||
expected_setex_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
|
||||
expected_add_calls = [call(doc_id) for doc_id in document_ids]
|
||||
patched_dependencies["redis_client"].get.assert_has_calls(expected_get_calls)
|
||||
patched_dependencies["redis_client"].setex.assert_has_calls(expected_setex_calls)
|
||||
patched_dependencies["add_task"].delay.assert_has_calls(expected_add_calls)
|
||||
|
||||
def test_batch_update_enable_already_enabled_document_skipped(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Skip enable operation for already-enabled documents."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
assert document.enabled is True
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_disable_documents_success(self, db_session_with_containers, patched_dependencies):
|
||||
"""Disable completed documents and trigger remove-index tasks."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document_ids = [str(uuid4()), str(uuid4())]
|
||||
enabled_docs = DocumentBatchUpdateIntegrationDataFactory.create_multiple_documents(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
enabled=True,
|
||||
indexing_status="completed",
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
action="disable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
for document in enabled_docs:
|
||||
db.session.refresh(document)
|
||||
self._assert_document_disabled(document, user.id, FIXED_TIME)
|
||||
|
||||
expected_get_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
|
||||
expected_setex_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
|
||||
expected_remove_calls = [call(doc_id) for doc_id in document_ids]
|
||||
patched_dependencies["redis_client"].get.assert_has_calls(expected_get_calls)
|
||||
patched_dependencies["redis_client"].setex.assert_has_calls(expected_setex_calls)
|
||||
patched_dependencies["remove_task"].delay.assert_has_calls(expected_remove_calls)
|
||||
|
||||
def test_batch_update_disable_already_disabled_document_skipped(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Skip disable operation for already-disabled documents."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
disabled_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
enabled=False,
|
||||
indexing_status="completed",
|
||||
completed_at=FIXED_TIME,
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[disabled_doc.id],
|
||||
action="disable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(disabled_doc)
|
||||
assert disabled_doc.enabled is False
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["remove_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_disable_non_completed_document_error(self, db_session_with_containers, patched_dependencies):
|
||||
"""Raise error when disabling a non-completed document."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
non_completed_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
enabled=True,
|
||||
indexing_status="indexing",
|
||||
completed_at=None,
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(DocumentIndexingError, match="is not completed"):
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[non_completed_doc.id],
|
||||
action="disable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
def test_batch_update_archive_documents_success(self, db_session_with_containers, patched_dependencies):
|
||||
"""Archive enabled documents and trigger remove-index task."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset, enabled=True, archived=False
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="archive",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
self._assert_document_archived(document, user.id, FIXED_TIME)
|
||||
patched_dependencies["redis_client"].get.assert_called_once_with(f"document_{document.id}_indexing")
|
||||
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{document.id}_indexing", 600, 1)
|
||||
patched_dependencies["remove_task"].delay.assert_called_once_with(document.id)
|
||||
|
||||
def test_batch_update_archive_already_archived_document_skipped(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Skip archive operation for already-archived documents."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset, enabled=True, archived=True
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="archive",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
assert document.archived is True
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["remove_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_archive_disabled_document_no_index_removal(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Archive disabled document without index-removal side effects."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset, enabled=False, archived=False
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="archive",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
self._assert_document_archived(document, user.id, FIXED_TIME)
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["remove_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_unarchive_documents_success(self, db_session_with_containers, patched_dependencies):
|
||||
"""Unarchive enabled documents and trigger add-index task."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset, enabled=True, archived=True
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="un_archive",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
self._assert_document_unarchived(document)
|
||||
assert document.updated_at == FIXED_TIME
|
||||
patched_dependencies["redis_client"].get.assert_called_once_with(f"document_{document.id}_indexing")
|
||||
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{document.id}_indexing", 600, 1)
|
||||
patched_dependencies["add_task"].delay.assert_called_once_with(document.id)
|
||||
|
||||
def test_batch_update_unarchive_already_unarchived_document_skipped(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Skip unarchive operation for already-unarchived documents."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset, enabled=True, archived=False
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="un_archive",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
assert document.archived is False
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_unarchive_disabled_document_no_index_addition(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Unarchive disabled document without index-add side effects."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset, enabled=False, archived=True
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="un_archive",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(document)
|
||||
self._assert_document_unarchived(document)
|
||||
assert document.updated_at == FIXED_TIME
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_document_indexing_error_redis_cache_hit(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Raise DocumentIndexingError when redis indicates active indexing."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
name="test_document.pdf",
|
||||
enabled=True,
|
||||
)
|
||||
patched_dependencies["redis_client"].get.return_value = "indexing"
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(DocumentIndexingError, match="is being indexed") as exc_info:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
assert "test_document.pdf" in str(exc_info.value)
|
||||
patched_dependencies["redis_client"].get.assert_called_once_with(f"document_{document.id}_indexing")
|
||||
|
||||
def test_batch_update_async_task_error_handling(self, db_session_with_containers, patched_dependencies):
|
||||
"""Persist DB update, then propagate async task error."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=False)
|
||||
patched_dependencies["add_task"].delay.side_effect = Exception("Celery task error")
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(Exception, match="Celery task error"):
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[document.id],
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
db.session.refresh(document)
|
||||
self._assert_document_enabled(document, FIXED_TIME)
|
||||
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{document.id}_indexing", 600, 1)
|
||||
|
||||
def test_batch_update_empty_document_list(self, db_session_with_containers, patched_dependencies):
|
||||
"""Return early when document_ids is empty."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
|
||||
# Act
|
||||
result = DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=[], action="enable", user=user
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
patched_dependencies["redis_client"].get.assert_not_called()
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
|
||||
def test_batch_update_document_not_found_skipped(self, db_session_with_containers, patched_dependencies):
|
||||
"""Skip IDs that do not map to existing dataset documents."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
missing_document_id = str(uuid4())
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=[missing_document_id],
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
patched_dependencies["redis_client"].get.assert_not_called()
|
||||
patched_dependencies["redis_client"].setex.assert_not_called()
|
||||
patched_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_mixed_document_states_and_actions(self, db_session_with_containers, patched_dependencies):
|
||||
"""Process only the applicable document in a mixed-state enable batch."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
disabled_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=False)
|
||||
enabled_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
enabled=True,
|
||||
position=2,
|
||||
)
|
||||
archived_doc = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
enabled=True,
|
||||
archived=True,
|
||||
position=3,
|
||||
)
|
||||
document_ids = [disabled_doc.id, enabled_doc.id, archived_doc.id]
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(disabled_doc)
|
||||
db.session.refresh(enabled_doc)
|
||||
db.session.refresh(archived_doc)
|
||||
self._assert_document_enabled(disabled_doc, FIXED_TIME)
|
||||
assert enabled_doc.enabled is True
|
||||
assert archived_doc.enabled is True
|
||||
|
||||
patched_dependencies["redis_client"].setex.assert_called_once_with(
|
||||
f"document_{disabled_doc.id}_indexing",
|
||||
600,
|
||||
1,
|
||||
)
|
||||
patched_dependencies["add_task"].delay.assert_called_once_with(disabled_doc.id)
|
||||
|
||||
def test_batch_update_large_document_list_performance(self, db_session_with_containers, patched_dependencies):
|
||||
"""Handle large document lists with consistent updates and side effects."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
document_ids = [str(uuid4()) for _ in range(100)]
|
||||
documents = DocumentBatchUpdateIntegrationDataFactory.create_multiple_documents(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
for document in documents:
|
||||
db.session.refresh(document)
|
||||
self._assert_document_enabled(document, FIXED_TIME)
|
||||
|
||||
assert patched_dependencies["redis_client"].setex.call_count == len(document_ids)
|
||||
assert patched_dependencies["add_task"].delay.call_count == len(document_ids)
|
||||
|
||||
expected_setex_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
|
||||
expected_task_calls = [call(doc_id) for doc_id in document_ids]
|
||||
patched_dependencies["redis_client"].setex.assert_has_calls(expected_setex_calls)
|
||||
patched_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
|
||||
|
||||
def test_batch_update_mixed_document_states_complex_scenario(
|
||||
self, db_session_with_containers, patched_dependencies
|
||||
):
|
||||
"""Process a complex mixed-state batch and update only eligible records."""
|
||||
# Arrange
|
||||
dataset = DocumentBatchUpdateIntegrationDataFactory.create_dataset()
|
||||
user = DocumentBatchUpdateIntegrationDataFactory.create_user()
|
||||
doc1 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=False)
|
||||
doc2 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True, position=2)
|
||||
doc3 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True, position=3)
|
||||
doc4 = DocumentBatchUpdateIntegrationDataFactory.create_document(dataset=dataset, enabled=True, position=4)
|
||||
doc5 = DocumentBatchUpdateIntegrationDataFactory.create_document(
|
||||
dataset=dataset,
|
||||
enabled=True,
|
||||
archived=True,
|
||||
position=5,
|
||||
)
|
||||
missing_id = str(uuid4())
|
||||
|
||||
document_ids = [doc1.id, doc2.id, doc3.id, doc4.id, doc5.id, missing_id]
|
||||
|
||||
# Act
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=document_ids,
|
||||
action="enable",
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Assert
|
||||
db.session.refresh(doc1)
|
||||
db.session.refresh(doc2)
|
||||
db.session.refresh(doc3)
|
||||
db.session.refresh(doc4)
|
||||
db.session.refresh(doc5)
|
||||
self._assert_document_enabled(doc1, FIXED_TIME)
|
||||
assert doc2.enabled is True
|
||||
assert doc3.enabled is True
|
||||
assert doc4.enabled is True
|
||||
assert doc5.enabled is True
|
||||
|
||||
patched_dependencies["redis_client"].setex.assert_called_once_with(f"document_{doc1.id}_indexing", 600, 1)
|
||||
patched_dependencies["add_task"].delay.assert_called_once_with(doc1.id)
|
||||
@@ -1,13 +1,10 @@
|
||||
import datetime
|
||||
|
||||
# Mock redis_client before importing dataset_service
|
||||
from unittest.mock import Mock, call, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from models.dataset import Dataset, Document
|
||||
from services.dataset_service import DocumentService
|
||||
from services.errors.document import DocumentIndexingError
|
||||
from tests.unit_tests.conftest import redis_mock
|
||||
|
||||
|
||||
@@ -48,7 +45,6 @@ class DocumentBatchUpdateTestDataFactory:
|
||||
document.indexing_status = indexing_status
|
||||
document.completed_at = completed_at or datetime.datetime.now()
|
||||
|
||||
# Set default values for optional fields
|
||||
document.disabled_at = None
|
||||
document.disabled_by = None
|
||||
document.archived_at = None
|
||||
@@ -59,32 +55,9 @@ class DocumentBatchUpdateTestDataFactory:
|
||||
setattr(document, key, value)
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
def create_multiple_documents(
|
||||
document_ids: list[str], enabled: bool = True, archived: bool = False, indexing_status: str = "completed"
|
||||
) -> list[Mock]:
|
||||
"""Create multiple mock documents with specified attributes."""
|
||||
documents = []
|
||||
for doc_id in document_ids:
|
||||
doc = DocumentBatchUpdateTestDataFactory.create_document_mock(
|
||||
document_id=doc_id,
|
||||
name=f"document_{doc_id}.pdf",
|
||||
enabled=enabled,
|
||||
archived=archived,
|
||||
indexing_status=indexing_status,
|
||||
)
|
||||
documents.append(doc)
|
||||
return documents
|
||||
|
||||
|
||||
class TestDatasetServiceBatchUpdateDocumentStatus:
|
||||
"""
|
||||
Comprehensive unit tests for DocumentService.batch_update_document_status method.
|
||||
|
||||
This test suite covers all supported actions (enable, disable, archive, un_archive),
|
||||
error conditions, edge cases, and validates proper interaction with Redis cache,
|
||||
database operations, and async task triggers.
|
||||
"""
|
||||
"""Unit tests for non-SQL path in DocumentService.batch_update_document_status."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_document_service_dependencies(self):
|
||||
@@ -104,697 +77,24 @@ class TestDatasetServiceBatchUpdateDocumentStatus:
|
||||
"current_time": current_time,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_async_task_dependencies(self):
|
||||
"""Mock setup for async task dependencies."""
|
||||
with (
|
||||
patch("services.dataset_service.add_document_to_index_task") as mock_add_task,
|
||||
patch("services.dataset_service.remove_document_from_index_task") as mock_remove_task,
|
||||
):
|
||||
yield {"add_task": mock_add_task, "remove_task": mock_remove_task}
|
||||
|
||||
def _assert_document_enabled(self, document: Mock, user_id: str, current_time: datetime.datetime):
|
||||
"""Helper method to verify document was enabled correctly."""
|
||||
assert document.enabled == True
|
||||
assert document.disabled_at is None
|
||||
assert document.disabled_by is None
|
||||
assert document.updated_at == current_time
|
||||
|
||||
def _assert_document_disabled(self, document: Mock, user_id: str, current_time: datetime.datetime):
|
||||
"""Helper method to verify document was disabled correctly."""
|
||||
assert document.enabled == False
|
||||
assert document.disabled_at == current_time
|
||||
assert document.disabled_by == user_id
|
||||
assert document.updated_at == current_time
|
||||
|
||||
def _assert_document_archived(self, document: Mock, user_id: str, current_time: datetime.datetime):
|
||||
"""Helper method to verify document was archived correctly."""
|
||||
assert document.archived == True
|
||||
assert document.archived_at == current_time
|
||||
assert document.archived_by == user_id
|
||||
assert document.updated_at == current_time
|
||||
|
||||
def _assert_document_unarchived(self, document: Mock):
|
||||
"""Helper method to verify document was unarchived correctly."""
|
||||
assert document.archived == False
|
||||
assert document.archived_at is None
|
||||
assert document.archived_by is None
|
||||
|
||||
def _assert_redis_cache_operations(self, document_ids: list[str], action: str = "setex"):
|
||||
"""Helper method to verify Redis cache operations."""
|
||||
if action == "setex":
|
||||
expected_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
|
||||
redis_mock.setex.assert_has_calls(expected_calls)
|
||||
elif action == "get":
|
||||
expected_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
|
||||
redis_mock.get.assert_has_calls(expected_calls)
|
||||
|
||||
def _assert_async_task_calls(self, mock_task, document_ids: list[str], task_type: str):
|
||||
"""Helper method to verify async task calls."""
|
||||
expected_calls = [call(doc_id) for doc_id in document_ids]
|
||||
if task_type in {"add", "remove"}:
|
||||
mock_task.delay.assert_has_calls(expected_calls)
|
||||
|
||||
# ==================== Enable Document Tests ====================
|
||||
|
||||
def test_batch_update_enable_documents_success(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test successful enabling of disabled documents."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create disabled documents
|
||||
disabled_docs = DocumentBatchUpdateTestDataFactory.create_multiple_documents(["doc-1", "doc-2"], enabled=False)
|
||||
mock_document_service_dependencies["get_document"].side_effect = disabled_docs
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Call the method to enable documents
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1", "doc-2"], action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify document attributes were updated correctly
|
||||
for doc in disabled_docs:
|
||||
self._assert_document_enabled(doc, user.id, mock_document_service_dependencies["current_time"])
|
||||
|
||||
# Verify Redis cache operations
|
||||
self._assert_redis_cache_operations(["doc-1", "doc-2"], "get")
|
||||
self._assert_redis_cache_operations(["doc-1", "doc-2"], "setex")
|
||||
|
||||
# Verify async tasks were triggered for indexing
|
||||
self._assert_async_task_calls(mock_async_task_dependencies["add_task"], ["doc-1", "doc-2"], "add")
|
||||
|
||||
# Verify database operations
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
assert mock_db.add.call_count == 2
|
||||
assert mock_db.commit.call_count == 1
|
||||
|
||||
def test_batch_update_enable_already_enabled_document_skipped(self, mock_document_service_dependencies):
|
||||
"""Test enabling documents that are already enabled."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create already enabled document
|
||||
enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = enabled_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Attempt to enable already enabled document
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify no database operations occurred (document was skipped)
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# Verify no Redis setex operations occurred (document was skipped)
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
# ==================== Disable Document Tests ====================
|
||||
|
||||
def test_batch_update_disable_documents_success(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test successful disabling of enabled and completed documents."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create enabled documents
|
||||
enabled_docs = DocumentBatchUpdateTestDataFactory.create_multiple_documents(["doc-1", "doc-2"], enabled=True)
|
||||
mock_document_service_dependencies["get_document"].side_effect = enabled_docs
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Call the method to disable documents
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1", "doc-2"], action="disable", user=user
|
||||
)
|
||||
|
||||
# Verify document attributes were updated correctly
|
||||
for doc in enabled_docs:
|
||||
self._assert_document_disabled(doc, user.id, mock_document_service_dependencies["current_time"])
|
||||
|
||||
# Verify Redis cache operations for indexing prevention
|
||||
self._assert_redis_cache_operations(["doc-1", "doc-2"], "setex")
|
||||
|
||||
# Verify async tasks were triggered to remove from index
|
||||
self._assert_async_task_calls(mock_async_task_dependencies["remove_task"], ["doc-1", "doc-2"], "remove")
|
||||
|
||||
# Verify database operations
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
assert mock_db.add.call_count == 2
|
||||
assert mock_db.commit.call_count == 1
|
||||
|
||||
def test_batch_update_disable_already_disabled_document_skipped(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test disabling documents that are already disabled."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create already disabled document
|
||||
disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False)
|
||||
mock_document_service_dependencies["get_document"].return_value = disabled_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Attempt to disable already disabled document
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="disable", user=user
|
||||
)
|
||||
|
||||
# Verify no database operations occurred (document was skipped)
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# Verify no Redis setex operations occurred (document was skipped)
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
# Verify no async tasks were triggered (document was skipped)
|
||||
mock_async_task_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_disable_non_completed_document_error(self, mock_document_service_dependencies):
|
||||
"""Test that DocumentIndexingError is raised when trying to disable non-completed documents."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create a document that's not completed
|
||||
non_completed_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(
|
||||
enabled=True,
|
||||
indexing_status="indexing", # Not completed
|
||||
completed_at=None, # Not completed
|
||||
)
|
||||
mock_document_service_dependencies["get_document"].return_value = non_completed_doc
|
||||
|
||||
# Verify that DocumentIndexingError is raised
|
||||
with pytest.raises(DocumentIndexingError) as exc_info:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="disable", user=user
|
||||
)
|
||||
|
||||
# Verify error message indicates document is not completed
|
||||
assert "is not completed" in str(exc_info.value)
|
||||
|
||||
# ==================== Archive Document Tests ====================
|
||||
|
||||
def test_batch_update_archive_documents_success(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test successful archiving of unarchived documents."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create unarchived enabled document
|
||||
unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=False)
|
||||
mock_document_service_dependencies["get_document"].return_value = unarchived_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Call the method to archive documents
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="archive", user=user
|
||||
)
|
||||
|
||||
# Verify document attributes were updated correctly
|
||||
self._assert_document_archived(unarchived_doc, user.id, mock_document_service_dependencies["current_time"])
|
||||
|
||||
# Verify Redis cache was set (because document was enabled)
|
||||
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
|
||||
|
||||
# Verify async task was triggered to remove from index (because enabled)
|
||||
mock_async_task_dependencies["remove_task"].delay.assert_called_once_with("doc-1")
|
||||
|
||||
# Verify database operations
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_batch_update_archive_already_archived_document_skipped(self, mock_document_service_dependencies):
|
||||
"""Test archiving documents that are already archived."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create already archived document
|
||||
archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = archived_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Attempt to archive already archived document
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-3"], action="archive", user=user
|
||||
)
|
||||
|
||||
# Verify no database operations occurred (document was skipped)
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# Verify no Redis setex operations occurred (document was skipped)
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
def test_batch_update_archive_disabled_document_no_index_removal(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test archiving disabled documents (should not trigger index removal)."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Set up disabled, unarchived document
|
||||
disabled_unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False, archived=False)
|
||||
mock_document_service_dependencies["get_document"].return_value = disabled_unarchived_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Archive the disabled document
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="archive", user=user
|
||||
)
|
||||
|
||||
# Verify document was archived
|
||||
self._assert_document_archived(
|
||||
disabled_unarchived_doc, user.id, mock_document_service_dependencies["current_time"]
|
||||
)
|
||||
|
||||
# Verify no Redis cache was set (document is disabled)
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
# Verify no index removal task was triggered (document is disabled)
|
||||
mock_async_task_dependencies["remove_task"].delay.assert_not_called()
|
||||
|
||||
# Verify database operations still occurred
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ==================== Unarchive Document Tests ====================
|
||||
|
||||
def test_batch_update_unarchive_documents_success(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test successful unarchiving of archived documents."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create mock archived document
|
||||
archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = archived_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Call the method to unarchive documents
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
|
||||
)
|
||||
|
||||
# Verify document attributes were updated correctly
|
||||
self._assert_document_unarchived(archived_doc)
|
||||
assert archived_doc.updated_at == mock_document_service_dependencies["current_time"]
|
||||
|
||||
# Verify Redis cache was set (because document is enabled)
|
||||
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
|
||||
|
||||
# Verify async task was triggered to add back to index (because enabled)
|
||||
mock_async_task_dependencies["add_task"].delay.assert_called_once_with("doc-1")
|
||||
|
||||
# Verify database operations
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
def test_batch_update_unarchive_already_unarchived_document_skipped(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test unarchiving documents that are already unarchived."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create already unarchived document
|
||||
unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=False)
|
||||
mock_document_service_dependencies["get_document"].return_value = unarchived_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Attempt to unarchive already unarchived document
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
|
||||
)
|
||||
|
||||
# Verify no database operations occurred (document was skipped)
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.commit.assert_not_called()
|
||||
|
||||
# Verify no Redis setex operations occurred (document was skipped)
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
# Verify no async tasks were triggered (document was skipped)
|
||||
mock_async_task_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
def test_batch_update_unarchive_disabled_document_no_index_addition(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test unarchiving disabled documents (should not trigger index addition)."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create mock archived but disabled document
|
||||
archived_disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False, archived=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = archived_disabled_doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Unarchive the disabled document
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
|
||||
)
|
||||
|
||||
# Verify document was unarchived
|
||||
self._assert_document_unarchived(archived_disabled_doc)
|
||||
assert archived_disabled_doc.updated_at == mock_document_service_dependencies["current_time"]
|
||||
|
||||
# Verify no Redis cache was set (document is disabled)
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
# Verify no index addition task was triggered (document is disabled)
|
||||
mock_async_task_dependencies["add_task"].delay.assert_not_called()
|
||||
|
||||
# Verify database operations still occurred
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# ==================== Error Handling Tests ====================
|
||||
|
||||
def test_batch_update_document_indexing_error_redis_cache_hit(self, mock_document_service_dependencies):
|
||||
"""Test that DocumentIndexingError is raised when documents are currently being indexed."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create mock enabled document
|
||||
enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = enabled_doc
|
||||
|
||||
# Set up mock to indicate document is being indexed
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = "indexing"
|
||||
|
||||
# Verify that DocumentIndexingError is raised
|
||||
with pytest.raises(DocumentIndexingError) as exc_info:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify error message contains document name
|
||||
assert "test_document.pdf" in str(exc_info.value)
|
||||
assert "is being indexed" in str(exc_info.value)
|
||||
|
||||
# Verify Redis cache was checked
|
||||
redis_mock.get.assert_called_once_with("document_doc-1_indexing")
|
||||
|
||||
def test_batch_update_invalid_action_error(self, mock_document_service_dependencies):
|
||||
"""Test that ValueError is raised when an invalid action is provided."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create mock document
|
||||
doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
|
||||
mock_document_service_dependencies["get_document"].return_value = doc
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Test with invalid action
|
||||
invalid_action = "invalid_action"
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action=invalid_action, user=user
|
||||
)
|
||||
|
||||
# Verify error message contains the invalid action
|
||||
assert invalid_action in str(exc_info.value)
|
||||
assert "Invalid action" in str(exc_info.value)
|
||||
|
||||
# Verify no Redis operations occurred
|
||||
redis_mock.setex.assert_not_called()
|
||||
|
||||
def test_batch_update_async_task_error_handling(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test handling of async task errors during batch operations."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create mock disabled document
|
||||
disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False)
|
||||
mock_document_service_dependencies["get_document"].return_value = disabled_doc
|
||||
|
||||
# Mock async task to raise an exception
|
||||
mock_async_task_dependencies["add_task"].delay.side_effect = Exception("Celery task error")
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Verify that async task error is propagated
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1"], action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify error message
|
||||
assert "Celery task error" in str(exc_info.value)
|
||||
|
||||
# Verify database operations completed successfully
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.add.assert_called_once()
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# Verify Redis cache was set successfully
|
||||
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
|
||||
|
||||
# Verify document was updated
|
||||
self._assert_document_enabled(disabled_doc, user.id, mock_document_service_dependencies["current_time"])
|
||||
|
||||
# ==================== Edge Case Tests ====================
|
||||
|
||||
def test_batch_update_empty_document_list(self, mock_document_service_dependencies):
|
||||
"""Test batch operations with an empty document ID list."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Call method with empty document list
|
||||
result = DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=[], action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify no document lookups were performed
|
||||
mock_document_service_dependencies["get_document"].assert_not_called()
|
||||
|
||||
# Verify method returns None (early return)
|
||||
assert result is None
|
||||
|
||||
def test_batch_update_document_not_found_skipped(self, mock_document_service_dependencies):
|
||||
"""Test behavior when some documents don't exist in the database."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Mock document service to return None (document not found)
|
||||
mock_document_service_dependencies["get_document"].return_value = None
|
||||
|
||||
# Call method with non-existent document ID
|
||||
# This should not raise an error, just skip the missing document
|
||||
try:
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["non-existent-doc"], action="enable", user=user
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Method should not raise exception for missing documents: {e}")
|
||||
|
||||
# Verify document lookup was attempted
|
||||
mock_document_service_dependencies["get_document"].assert_called_once_with(dataset.id, "non-existent-doc")
|
||||
|
||||
def test_batch_update_mixed_document_states_and_actions(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test batch operations on documents with mixed states and various scenarios."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create documents in various states
|
||||
disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-1", enabled=False)
|
||||
enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-2", enabled=True)
|
||||
archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-3", enabled=True, archived=True)
|
||||
|
||||
# Mix of different document states
|
||||
documents = [disabled_doc, enabled_doc, archived_doc]
|
||||
mock_document_service_dependencies["get_document"].side_effect = documents
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Perform enable operation on mixed state documents
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=["doc-1", "doc-2", "doc-3"], action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify only the disabled document was processed
|
||||
# (enabled and archived documents should be skipped for enable action)
|
||||
|
||||
# Only one add should occur (for the disabled document that was enabled)
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
mock_db.add.assert_called_once()
|
||||
# Only one commit should occur
|
||||
mock_db.commit.assert_called_once()
|
||||
|
||||
# Only one Redis setex should occur (for the document that was enabled)
|
||||
redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
|
||||
|
||||
# Only one async task should be triggered (for the document that was enabled)
|
||||
mock_async_task_dependencies["add_task"].delay.assert_called_once_with("doc-1")
|
||||
|
||||
# ==================== Performance Tests ====================
|
||||
|
||||
def test_batch_update_large_document_list_performance(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test batch operations with a large number of documents."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create large list of document IDs
|
||||
document_ids = [f"doc-{i}" for i in range(1, 101)] # 100 documents
|
||||
|
||||
# Create mock documents
|
||||
mock_documents = DocumentBatchUpdateTestDataFactory.create_multiple_documents(
|
||||
document_ids,
|
||||
enabled=False, # All disabled, will be enabled
|
||||
)
|
||||
mock_document_service_dependencies["get_document"].side_effect = mock_documents
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Perform batch enable operation
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset, document_ids=document_ids, action="enable", user=user
|
||||
)
|
||||
|
||||
# Verify all documents were processed
|
||||
assert mock_document_service_dependencies["get_document"].call_count == 100
|
||||
|
||||
# Verify all documents were updated
|
||||
for mock_doc in mock_documents:
|
||||
self._assert_document_enabled(mock_doc, user.id, mock_document_service_dependencies["current_time"])
|
||||
|
||||
# Verify database operations
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
assert mock_db.add.call_count == 100
|
||||
assert mock_db.commit.call_count == 1
|
||||
|
||||
# Verify Redis cache operations occurred for each document
|
||||
assert redis_mock.setex.call_count == 100
|
||||
|
||||
# Verify async tasks were triggered for each document
|
||||
assert mock_async_task_dependencies["add_task"].delay.call_count == 100
|
||||
|
||||
# Verify correct Redis cache keys were set
|
||||
expected_redis_calls = [call(f"document_doc-{i}_indexing", 600, 1) for i in range(1, 101)]
|
||||
redis_mock.setex.assert_has_calls(expected_redis_calls)
|
||||
|
||||
# Verify correct async task calls
|
||||
expected_task_calls = [call(f"doc-{i}") for i in range(1, 101)]
|
||||
mock_async_task_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
|
||||
|
||||
def test_batch_update_mixed_document_states_complex_scenario(
|
||||
self, mock_document_service_dependencies, mock_async_task_dependencies
|
||||
):
|
||||
"""Test complex batch operations with documents in various states."""
|
||||
dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
|
||||
user = DocumentBatchUpdateTestDataFactory.create_user_mock()
|
||||
|
||||
# Create documents in various states
|
||||
doc1 = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-1", enabled=False) # Will be enabled
|
||||
doc2 = DocumentBatchUpdateTestDataFactory.create_document_mock(
|
||||
"doc-2", enabled=True
|
||||
) # Already enabled, will be skipped
|
||||
doc3 = DocumentBatchUpdateTestDataFactory.create_document_mock(
|
||||
"doc-3", enabled=True
|
||||
) # Already enabled, will be skipped
|
||||
doc4 = DocumentBatchUpdateTestDataFactory.create_document_mock(
|
||||
"doc-4", enabled=True
|
||||
) # Not affected by enable action
|
||||
doc5 = DocumentBatchUpdateTestDataFactory.create_document_mock(
|
||||
"doc-5", enabled=True, archived=True
|
||||
) # Not affected by enable action
|
||||
doc6 = None # Non-existent, will be skipped
|
||||
|
||||
mock_document_service_dependencies["get_document"].side_effect = [doc1, doc2, doc3, doc4, doc5, doc6]
|
||||
|
||||
# Reset module-level Redis mock
|
||||
redis_mock.reset_mock()
|
||||
redis_mock.get.return_value = None
|
||||
|
||||
# Perform mixed batch operations
|
||||
DocumentService.batch_update_document_status(
|
||||
dataset=dataset,
|
||||
document_ids=["doc-1", "doc-2", "doc-3", "doc-4", "doc-5", "doc-6"],
|
||||
action="enable", # This will only affect doc1
|
||||
user=user,
|
||||
)
|
||||
|
||||
# Verify document 1 was enabled
|
||||
self._assert_document_enabled(doc1, user.id, mock_document_service_dependencies["current_time"])
|
||||
|
||||
# Verify other documents were skipped appropriately
|
||||
assert doc2.enabled == True # No change
|
||||
assert doc3.enabled == True # No change
|
||||
assert doc4.enabled == True # No change
|
||||
assert doc5.enabled == True # No change
|
||||
|
||||
# Verify database commits occurred for processed documents
|
||||
# Only doc1 should be added (others were skipped, doc6 doesn't exist)
|
||||
mock_db = mock_document_service_dependencies["db_session"]
|
||||
assert mock_db.add.call_count == 1
|
||||
assert mock_db.commit.call_count == 1
|
||||
|
||||
# Verify Redis cache operations occurred for processed documents
|
||||
# Only doc1 should have Redis operations
|
||||
assert redis_mock.setex.call_count == 1
|
||||
|
||||
# Verify async tasks were triggered for processed documents
|
||||
# Only doc1 should trigger tasks
|
||||
assert mock_async_task_dependencies["add_task"].delay.call_count == 1
|
||||
|
||||
# Verify correct Redis cache keys were set
|
||||
expected_redis_calls = [call("document_doc-1_indexing", 600, 1)]
|
||||
redis_mock.setex.assert_has_calls(expected_redis_calls)
|
||||
|
||||
# Verify correct async task calls
|
||||
expected_task_calls = [call("doc-1")]
|
||||
mock_async_task_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
|
||||
|
||||
@@ -1 +1 @@
|
||||
24
|
||||
22
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# base image
|
||||
FROM node:24-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
LABEL maintainer="takatost@gmail.com"
|
||||
|
||||
# if you located in China, you can use aliyun mirror to speed up
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@@ -161,10 +161,9 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse =>
|
||||
})
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
return renderWithNuqs(
|
||||
<List controlRefreshList={0} />,
|
||||
{ searchParams },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,11 +208,7 @@ describe('App List Browsing Flow', () => {
|
||||
|
||||
it('should transition from loading to content when data loads', () => {
|
||||
mockIsLoading = true
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
@@ -224,11 +219,7 @@ describe('App List Browsing Flow', () => {
|
||||
createMockApp({ id: 'app-1', name: 'Loaded App' }),
|
||||
])]
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List controlRefreshList={0} />)
|
||||
|
||||
expect(screen.getByText('Loaded App')).toBeInTheDocument()
|
||||
})
|
||||
@@ -424,17 +415,9 @@ describe('App List Browsing Flow', () => {
|
||||
it('should call refetch when controlRefreshList increments', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = renderWithNuqs(<List controlRefreshList={0} />)
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={1} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List controlRefreshList={1} />)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
@@ -214,11 +214,7 @@ const createPage = (apps: App[]): AppListResponse => ({
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
return render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
return renderWithNuqs(<List controlRefreshList={0} />)
|
||||
}
|
||||
|
||||
describe('Create App Flow', () => {
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
*/
|
||||
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('next/navigation', () => ({
|
||||
@@ -28,12 +29,16 @@ const { useDocumentSort } = await import(
|
||||
const { useDocumentSelection } = await import(
|
||||
'@/app/components/datasets/documents/components/document-list/hooks/use-document-selection',
|
||||
)
|
||||
const { default: useDocumentListQueryState } = await import(
|
||||
const { useDocumentListQueryState } = await import(
|
||||
'@/app/components/datasets/documents/hooks/use-document-list-query-state',
|
||||
)
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const renderQueryStateHook = (searchParams = '') => {
|
||||
return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams })
|
||||
}
|
||||
|
||||
const createDoc = (overrides?: Partial<LocalDoc>): LocalDoc => ({
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
@@ -85,7 +90,7 @@ describe('Document Management Flow', () => {
|
||||
|
||||
describe('URL-based Query State', () => {
|
||||
it('should parse default query from empty URL params', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
const { result } = renderQueryStateHook()
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
@@ -96,107 +101,85 @@ describe('Document Management Flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should update query and push to router', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
it('should update keyword query with replace history', async () => {
|
||||
const { result, onUrlUpdate } = renderQueryStateHook()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test', page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// The push call should contain the updated query params
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toContain('keyword=test')
|
||||
expect(pushUrl).toContain('page=2')
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
expect(update.searchParams.get('keyword')).toBe('test')
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('should reset query to defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
it('should reset query to defaults', async () => {
|
||||
const { result, onUrlUpdate } = renderQueryStateHook()
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalled()
|
||||
// Default query omits default values from URL
|
||||
const pushUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushUrl).toBe('/datasets/ds-1/documents')
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.options.history).toBe('replace')
|
||||
expect(update.searchParams.toString()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Document Sort Integration', () => {
|
||||
it('should return documents unsorted when no sort field set', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt', word_count: 300 }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt', word_count: 100 }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt', word_count: 200 }),
|
||||
]
|
||||
|
||||
it('should derive sort field and order from remote sort value', () => {
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange: vi.fn(),
|
||||
}))
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortedDocuments).toHaveLength(3)
|
||||
expect(result.current.sortField).toBe('created_at')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'Banana.txt' }),
|
||||
createDoc({ id: 'doc-2', name: 'Apple.txt' }),
|
||||
createDoc({ id: 'doc-3', name: 'Cherry.txt' }),
|
||||
]
|
||||
|
||||
it('should call remote sort change with descending sort for a new field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Cherry.txt', 'Banana.txt', 'Apple.txt'])
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count')
|
||||
})
|
||||
|
||||
it('should toggle sort order on same field click', () => {
|
||||
const docs = [createDoc({ id: 'doc-1', name: 'A.txt' }), createDoc({ id: 'doc-2', name: 'B.txt' })]
|
||||
|
||||
it('should toggle descending to ascending when clicking active field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
remoteSortValue: '-hit_count',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
act(() => {
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
act(() => result.current.handleSort('name'))
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count')
|
||||
})
|
||||
|
||||
it('should filter by status before sorting', () => {
|
||||
const docs = [
|
||||
createDoc({ id: 'doc-1', name: 'A.txt', display_status: 'available' }),
|
||||
createDoc({ id: 'doc-2', name: 'B.txt', display_status: 'error' }),
|
||||
createDoc({ id: 'doc-3', name: 'C.txt', display_status: 'available' }),
|
||||
]
|
||||
|
||||
it('should ignore null sort field updates', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: 'available',
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
// Only 'available' documents should remain
|
||||
expect(result.current.sortedDocuments).toHaveLength(2)
|
||||
expect(result.current.sortedDocuments.every(d => d.display_status === 'available')).toBe(true)
|
||||
act(() => {
|
||||
result.current.handleSort(null)
|
||||
})
|
||||
|
||||
expect(onRemoteSortChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,14 +292,13 @@ describe('Document Management Flow', () => {
|
||||
describe('Cross-Module: Query State → Sort → Selection Pipeline', () => {
|
||||
it('should maintain consistent default state across all hooks', () => {
|
||||
const docs = [createDoc({ id: 'doc-1' })]
|
||||
const { result: queryResult } = renderHook(() => useDocumentListQueryState())
|
||||
const { result: queryResult } = renderQueryStateHook()
|
||||
const { result: sortResult } = renderHook(() => useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: queryResult.current.query.status,
|
||||
remoteSortValue: queryResult.current.query.sort,
|
||||
onRemoteSortChange: vi.fn(),
|
||||
}))
|
||||
const { result: selResult } = renderHook(() => useDocumentSelection({
|
||||
documents: sortResult.current.sortedDocuments,
|
||||
documents: docs,
|
||||
selectedIds: [],
|
||||
onSelectedIdChange: vi.fn(),
|
||||
}))
|
||||
@@ -325,8 +307,9 @@ describe('Document Management Flow', () => {
|
||||
expect(queryResult.current.query.sort).toBe('-created_at')
|
||||
expect(queryResult.current.query.status).toBe('all')
|
||||
|
||||
// Sort inherits 'all' status → no filtering applied
|
||||
expect(sortResult.current.sortedDocuments).toHaveLength(1)
|
||||
// Sort state is derived from URL default sort.
|
||||
expect(sortResult.current.sortField).toBe('created_at')
|
||||
expect(sortResult.current.sortOrder).toBe('desc')
|
||||
|
||||
// Selection starts empty
|
||||
expect(selResult.current.isAllSelected).toBe(false)
|
||||
|
||||
@@ -28,9 +28,13 @@ vi.mock('react-i18next', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}))
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: () => ['builtin', vi.fn()],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: () => ({ enable_marketplace: false }),
|
||||
@@ -212,6 +216,12 @@ vi.mock('@/app/components/tools/marketplace', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/marketplace/hooks', () => ({
|
||||
useMarketplace: () => ({
|
||||
handleScroll: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/tools/mcp', () => ({
|
||||
default: () => <div data-testid="mcp-list">MCP List</div>,
|
||||
}))
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
import List from '../list'
|
||||
@@ -186,21 +184,14 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with NuqsTestingAdapter
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const renderList = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
return render(<List />, { wrapper })
|
||||
return renderWithNuqs(<List />, { searchParams })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onUrlUpdate.mockClear()
|
||||
useTagStore.setState({
|
||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||
showTagManagementModal: false,
|
||||
@@ -277,7 +268,7 @@ describe('List', () => {
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
renderList()
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
|
||||
@@ -287,7 +278,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
renderList('?category=workflow')
|
||||
const { onUrlUpdate } = renderList('?category=workflow')
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
@@ -391,18 +382,10 @@ describe('List', () => {
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -448,7 +431,7 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
renderList()
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
import useAppsQueryState from '../use-apps-query-state'
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
const { result } = renderHook(() => useAppsQueryState(), { wrapper })
|
||||
return { result, onUrlUpdate }
|
||||
return renderHookWithNuqs(() => useAppsQueryState(), { searchParams })
|
||||
}
|
||||
|
||||
describe('useAppsQueryState', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { FC } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
@@ -16,7 +16,7 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
@@ -33,6 +33,18 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
}
|
||||
@@ -45,7 +57,7 @@ const List: FC<Props> = ({
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
const [activeTab, setActiveTab] = useQueryState(
|
||||
'category',
|
||||
parseAsString.withDefault('all').withOptions({ history: 'push' }),
|
||||
parseAsAppListCategory,
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
@@ -80,7 +92,7 @@ const List: FC<Props> = ({
|
||||
name: searchKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab as AppModeEnum } : {}),
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -186,7 +198,10 @@ const List: FC<Props> = ({
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @deprecated Use `@/app/components/base/ui/dialog` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*/
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @deprecated Use `@/app/components/base/ui/dialog` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*/
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { memo } from 'react'
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
'use client'
|
||||
/**
|
||||
* @deprecated Use semantic overlay primitives from `@/app/components/base/ui/` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*
|
||||
* Migration guide:
|
||||
* - Tooltip → `@/app/components/base/ui/tooltip`
|
||||
* - Menu/Dropdown → `@/app/components/base/ui/dropdown-menu`
|
||||
* - Popover → `@/app/components/base/ui/popover`
|
||||
* - Dialog/Modal → `@/app/components/base/ui/dialog`
|
||||
* - Select → `@/app/components/base/ui/select`
|
||||
*/
|
||||
import type { OffsetOptions, Placement } from '@floating-ui/react'
|
||||
import {
|
||||
autoUpdate,
|
||||
@@ -33,6 +45,7 @@ export type PortalToFollowElemOptions = {
|
||||
triggerPopupSameWidth?: boolean
|
||||
}
|
||||
|
||||
/** @deprecated Use semantic overlay primitives instead. See #32767. */
|
||||
export function usePortalToFollowElem({
|
||||
placement = 'bottom',
|
||||
open: controlledOpen,
|
||||
@@ -110,6 +123,7 @@ export function usePortalToFollowElemContext() {
|
||||
return context
|
||||
}
|
||||
|
||||
/** @deprecated Use semantic overlay primitives instead. See #32767. */
|
||||
export function PortalToFollowElem({
|
||||
children,
|
||||
...options
|
||||
@@ -124,6 +138,7 @@ export function PortalToFollowElem({
|
||||
)
|
||||
}
|
||||
|
||||
/** @deprecated Use semantic overlay primitives instead. See #32767. */
|
||||
export const PortalToFollowElemTrigger = (
|
||||
{
|
||||
ref: propRef,
|
||||
@@ -164,6 +179,7 @@ export const PortalToFollowElemTrigger = (
|
||||
}
|
||||
PortalToFollowElemTrigger.displayName = 'PortalToFollowElemTrigger'
|
||||
|
||||
/** @deprecated Use semantic overlay primitives instead. See #32767. */
|
||||
export const PortalToFollowElemContent = (
|
||||
{
|
||||
ref: propRef,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
'use client'
|
||||
/**
|
||||
* @deprecated Use `@/app/components/base/ui/select` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
|
||||
@@ -236,7 +241,7 @@ const SimpleSelect: FC<ISelectProps> = ({
|
||||
}}
|
||||
className={cn(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}
|
||||
>
|
||||
<span className={cn('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
|
||||
<span className={cn('block truncate text-left text-components-input-text-filled system-sm-regular', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{isLoading
|
||||
? <RiLoader4Line className="h-3.5 w-3.5 animate-spin text-text-secondary" />
|
||||
|
||||
@@ -77,11 +77,11 @@ const Toast = ({
|
||||
</div>
|
||||
<div className={cn('flex grow flex-col items-start gap-1 py-1', size === 'md' ? 'px-1' : 'px-0.5')}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-text-primary system-sm-semibold [word-break:break-word]">{message}</div>
|
||||
<div className="system-sm-semibold text-text-primary [word-break:break-word]">{message}</div>
|
||||
{customComponent}
|
||||
</div>
|
||||
{!!children && (
|
||||
<div className="text-text-secondary system-xs-regular">
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -149,26 +149,25 @@ Toast.notify = ({
|
||||
if (typeof window === 'object') {
|
||||
const holder = document.createElement('div')
|
||||
const root = createRoot(holder)
|
||||
let timerId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const unmountAndRemove = () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId)
|
||||
timerId = undefined
|
||||
}
|
||||
if (typeof window !== 'undefined' && holder) {
|
||||
toastHandler.clear = () => {
|
||||
if (holder) {
|
||||
root.unmount()
|
||||
holder.remove()
|
||||
}
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
toastHandler.clear = unmountAndRemove
|
||||
|
||||
root.render(
|
||||
<ToastContext.Provider value={{
|
||||
notify: noop,
|
||||
close: unmountAndRemove,
|
||||
close: () => {
|
||||
if (holder) {
|
||||
root.unmount()
|
||||
holder.remove()
|
||||
}
|
||||
onClose?.()
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toast type={type} size={size} message={message} duration={duration} className={className} customComponent={customComponent} />
|
||||
@@ -177,7 +176,7 @@ Toast.notify = ({
|
||||
document.body.appendChild(holder)
|
||||
const d = duration ?? defaultDuring
|
||||
if (d > 0)
|
||||
timerId = setTimeout(unmountAndRemove, d)
|
||||
setTimeout(toastHandler.clear, d)
|
||||
}
|
||||
|
||||
return toastHandler
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
'use client'
|
||||
/**
|
||||
* @deprecated Use `@/app/components/base/ui/tooltip` instead.
|
||||
* This component will be removed after migration is complete.
|
||||
* See: https://github.com/langgenius/dify/issues/32767
|
||||
*/
|
||||
import type { OffsetOptions, Placement } from '@floating-ui/react'
|
||||
import type { FC } from 'react'
|
||||
import { RiQuestionLine } from '@remixicon/react'
|
||||
@@ -130,7 +135,7 @@ const Tooltip: FC<TooltipProps> = ({
|
||||
{!!popupContent && (
|
||||
<div
|
||||
className={cn(
|
||||
!noDecoration && 'system-xs-regular relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg',
|
||||
!noDecoration && 'relative max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
|
||||
popupClassName,
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
|
||||
70
web/app/components/base/ui/dialog/__tests__/index.spec.tsx
Normal file
70
web/app/components/base/ui/dialog/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '../index'
|
||||
|
||||
describe('Dialog wrapper', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render dialog content when dialog is open', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogTitle>Dialog Title</DialogTitle>
|
||||
<DialogDescription>Dialog Description</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveTextContent('Dialog Title')
|
||||
expect(dialog).toHaveTextContent('Dialog Description')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should not render close button when closable is omitted', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<span>Dialog body</span>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button when closable is true', () => {
|
||||
render(
|
||||
<Dialog open>
|
||||
<DialogContent closable>
|
||||
<span>Dialog body</span>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' })
|
||||
|
||||
expect(dialog).toContainElement(closeButton)
|
||||
expect(closeButton).toHaveAttribute('aria-label', 'Close')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Exports', () => {
|
||||
it('should map dialog aliases to the matching base dialog primitives', () => {
|
||||
expect(Dialog).toBe(BaseDialog.Root)
|
||||
expect(DialogTrigger).toBe(BaseDialog.Trigger)
|
||||
expect(DialogTitle).toBe(BaseDialog.Title)
|
||||
expect(DialogDescription).toBe(BaseDialog.Description)
|
||||
expect(DialogClose).toBe(BaseDialog.Close)
|
||||
})
|
||||
})
|
||||
})
|
||||
58
web/app/components/base/ui/dialog/index.tsx
Normal file
58
web/app/components/base/ui/dialog/index.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
// z-index strategy (relies on root `isolation: isolate` in layout.tsx):
|
||||
// All overlay primitives (Tooltip / Popover / Dropdown / Select / Dialog) — z-50
|
||||
// Overlays share the same z-index; DOM order handles stacking when multiple are open.
|
||||
// This ensures overlays inside a Dialog (e.g. a Tooltip on a dialog button) render
|
||||
// above the dialog backdrop instead of being clipped by it.
|
||||
// Toast — z-[99], always on top (defined in toast component)
|
||||
|
||||
import { Dialog as BaseDialog } from '@base-ui/react/dialog'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const Dialog = BaseDialog.Root
|
||||
export const DialogTrigger = BaseDialog.Trigger
|
||||
export const DialogTitle = BaseDialog.Title
|
||||
export const DialogDescription = BaseDialog.Description
|
||||
export const DialogClose = BaseDialog.Close
|
||||
|
||||
type DialogContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
overlayClassName?: string
|
||||
closable?: boolean
|
||||
}
|
||||
|
||||
export function DialogContent({
|
||||
children,
|
||||
className,
|
||||
overlayClassName,
|
||||
closable = false,
|
||||
}: DialogContentProps) {
|
||||
return (
|
||||
<BaseDialog.Portal>
|
||||
<BaseDialog.Backdrop
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background-overlay',
|
||||
'transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
overlayClassName,
|
||||
)}
|
||||
/>
|
||||
<BaseDialog.Popup
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 max-h-[80dvh] w-[480px] max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl',
|
||||
'transition-[transform,scale,opacity] duration-150 data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{closable && (
|
||||
<BaseDialog.Close aria-label="Close" className="absolute right-6 top-6 z-10 flex h-5 w-5 cursor-pointer items-center justify-center rounded-2xl hover:bg-state-base-hover">
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</BaseDialog.Close>
|
||||
)}
|
||||
{children}
|
||||
</BaseDialog.Popup>
|
||||
</BaseDialog.Portal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '../index'
|
||||
|
||||
describe('dropdown-menu wrapper', () => {
|
||||
describe('alias exports', () => {
|
||||
it('should map direct aliases to the corresponding Menu primitive when importing menu roots', () => {
|
||||
expect(DropdownMenu).toBe(Menu.Root)
|
||||
expect(DropdownMenuPortal).toBe(Menu.Portal)
|
||||
expect(DropdownMenuTrigger).toBe(Menu.Trigger)
|
||||
expect(DropdownMenuSub).toBe(Menu.SubmenuRoot)
|
||||
expect(DropdownMenuGroup).toBe(Menu.Group)
|
||||
expect(DropdownMenuRadioGroup).toBe(Menu.RadioGroup)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuContent', () => {
|
||||
it('should position content at bottom-end with default placement when props are omitted', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent positionerProps={{ 'role': 'group', 'aria-label': 'content positioner' }}>
|
||||
<DropdownMenuItem>Content action</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
|
||||
expect(positioner).toHaveAttribute('data-side', 'bottom')
|
||||
expect(positioner).toHaveAttribute('data-align', 'end')
|
||||
expect(within(popup).getByRole('menuitem', { name: 'Content action' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom placement when custom positioning props are provided', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="top-start"
|
||||
sideOffset={12}
|
||||
alignOffset={-3}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'custom content positioner' }}
|
||||
>
|
||||
<DropdownMenuItem>Custom content</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'custom content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
|
||||
expect(positioner).toHaveAttribute('data-side', 'top')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
expect(within(popup).getByRole('menuitem', { name: 'Custom content' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward passthrough attributes and handlers when positionerProps and popupProps are provided', () => {
|
||||
const handlePositionerMouseEnter = vi.fn()
|
||||
const handlePopupClick = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'dropdown content positioner',
|
||||
'id': 'dropdown-content-positioner',
|
||||
'onMouseEnter': handlePositionerMouseEnter,
|
||||
}}
|
||||
popupProps={{
|
||||
role: 'menu',
|
||||
id: 'dropdown-content-popup',
|
||||
onClick: handlePopupClick,
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem>Passthrough content</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'dropdown content positioner' })
|
||||
const popup = screen.getByRole('menu')
|
||||
fireEvent.mouseEnter(positioner)
|
||||
fireEvent.click(popup)
|
||||
|
||||
expect(positioner).toHaveAttribute('id', 'dropdown-content-positioner')
|
||||
expect(popup).toHaveAttribute('id', 'dropdown-content-popup')
|
||||
expect(handlePositionerMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(handlePopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuSubContent', () => {
|
||||
it('should position sub-content at left-start with default placement when props are omitted', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent positionerProps={{ 'role': 'group', 'aria-label': 'sub positioner' }}>
|
||||
<DropdownMenuItem>Sub action</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'sub positioner' })
|
||||
expect(positioner).toHaveAttribute('data-side', 'left')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
expect(screen.getByRole('menuitem', { name: 'Sub action' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom placement and forward passthrough props for sub-content when custom props are provided', () => {
|
||||
const handlePositionerFocus = vi.fn()
|
||||
const handlePopupClick = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
placement="right-end"
|
||||
sideOffset={6}
|
||||
alignOffset={2}
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'dropdown sub positioner',
|
||||
'id': 'dropdown-sub-positioner',
|
||||
'onFocus': handlePositionerFocus,
|
||||
}}
|
||||
popupProps={{
|
||||
role: 'menu',
|
||||
id: 'dropdown-sub-popup',
|
||||
onClick: handlePopupClick,
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem>Custom sub action</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'dropdown sub positioner' })
|
||||
const popup = screen.getByRole('menu', { name: 'More actions' })
|
||||
fireEvent.focus(positioner)
|
||||
fireEvent.click(popup)
|
||||
|
||||
expect(positioner).toHaveAttribute('data-side', 'right')
|
||||
expect(positioner).toHaveAttribute('data-align', 'end')
|
||||
expect(positioner).toHaveAttribute('id', 'dropdown-sub-positioner')
|
||||
expect(popup).toHaveAttribute('id', 'dropdown-sub-popup')
|
||||
expect(handlePositionerFocus).toHaveBeenCalledTimes(1)
|
||||
expect(handlePopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuSubTrigger', () => {
|
||||
it('should render submenu trigger content when trigger children are provided', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>Trigger item</DropdownMenuSubTrigger>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Trigger item' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger
|
||||
destructive={destructive}
|
||||
aria-label="submenu action"
|
||||
id={`submenu-trigger-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Trigger item
|
||||
</DropdownMenuSubTrigger>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const subTrigger = screen.getByRole('menuitem', { name: 'submenu action' })
|
||||
fireEvent.click(subTrigger)
|
||||
|
||||
expect(subTrigger).toHaveAttribute('id', `submenu-trigger-${String(destructive)}`)
|
||||
expect(subTrigger).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuItem', () => {
|
||||
it.each([true, false])('should remain interactive and not leak destructive prop when destructive is %s', (destructive) => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
destructive={destructive}
|
||||
aria-label="menu action"
|
||||
id={`menu-item-${String(destructive)}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Item label
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('menuitem', { name: 'menu action' })
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(item).toHaveAttribute('id', `menu-item-${String(destructive)}`)
|
||||
expect(item).not.toHaveAttribute('destructive')
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DropdownMenuSeparator', () => {
|
||||
it('should forward passthrough props and handlers when separator props are provided', () => {
|
||||
const handleMouseEnter = vi.fn()
|
||||
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSeparator
|
||||
aria-label="actions divider"
|
||||
id="menu-separator"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
const separator = screen.getByRole('separator', { name: 'actions divider' })
|
||||
fireEvent.mouseEnter(separator)
|
||||
|
||||
expect(separator).toHaveAttribute('id', 'menu-separator')
|
||||
expect(handleMouseEnter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should keep surrounding menu rows rendered when separator is placed between items', () => {
|
||||
render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuTrigger aria-label="menu trigger">Open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>First action</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Second action</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'First action' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('menuitem', { name: 'Second action' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
277
web/app/components/base/ui/dropdown-menu/index.tsx
Normal file
277
web/app/components/base/ui/dropdown-menu/index.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { Menu } from '@base-ui/react/menu'
|
||||
import * as React from 'react'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const DropdownMenu = Menu.Root
|
||||
export const DropdownMenuPortal = Menu.Portal
|
||||
export const DropdownMenuTrigger = Menu.Trigger
|
||||
export const DropdownMenuSub = Menu.SubmenuRoot
|
||||
export const DropdownMenuGroup = Menu.Group
|
||||
export const DropdownMenuRadioGroup = Menu.RadioGroup
|
||||
|
||||
const menuRowBaseClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center rounded-lg px-2 outline-none'
|
||||
const menuRowStateClassName = 'data-[highlighted]:bg-state-base-hover data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50'
|
||||
|
||||
export function DropdownMenuRadioItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.RadioItem>) {
|
||||
return (
|
||||
<Menu.RadioItem
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownMenuRadioItemIndicator({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.RadioItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<Menu.RadioItemIndicator
|
||||
className={cn(
|
||||
'ml-auto flex shrink-0 items-center text-text-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
</Menu.RadioItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.CheckboxItem>) {
|
||||
return (
|
||||
<Menu.CheckboxItem
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownMenuCheckboxItemIndicator({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.ComponentPropsWithoutRef<typeof Menu.CheckboxItemIndicator>, 'children'>) {
|
||||
return (
|
||||
<Menu.CheckboxItemIndicator
|
||||
className={cn(
|
||||
'ml-auto flex shrink-0 items-center text-text-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className="i-ri-check-line h-4 w-4" />
|
||||
</Menu.CheckboxItemIndicator>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownMenuGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
|
||||
return (
|
||||
<Menu.GroupLabel
|
||||
className={cn(
|
||||
'px-3 py-1 text-text-tertiary system-2xs-medium-uppercase',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof Menu.Positioner>,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof Menu.Popup>,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
type DropdownMenuPopupRenderProps = Required<Pick<DropdownMenuContentProps, 'children'>> & {
|
||||
placement: Placement
|
||||
sideOffset: number
|
||||
alignOffset: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: DropdownMenuContentProps['positionerProps']
|
||||
popupProps?: DropdownMenuContentProps['popupProps']
|
||||
}
|
||||
|
||||
function renderDropdownMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: DropdownMenuPopupRenderProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-50 outline-none', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<Menu.Popup
|
||||
className={cn(
|
||||
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-1 text-sm text-text-secondary shadow-lg',
|
||||
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownMenuContent({
|
||||
children,
|
||||
placement = 'bottom-end',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: DropdownMenuContentProps) {
|
||||
return renderDropdownMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
})
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof Menu.SubmenuTrigger> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function DropdownMenuSubTrigger({
|
||||
className,
|
||||
destructive,
|
||||
children,
|
||||
...props
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
return (
|
||||
<Menu.SubmenuTrigger
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
destructive && 'text-text-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Menu.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: DropdownMenuContentProps['positionerProps']
|
||||
popupProps?: DropdownMenuContentProps['popupProps']
|
||||
}
|
||||
|
||||
export function DropdownMenuSubContent({
|
||||
children,
|
||||
placement = 'left-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: DropdownMenuSubContentProps) {
|
||||
return renderDropdownMenuPopup({
|
||||
children,
|
||||
placement,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
})
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps = React.ComponentPropsWithoutRef<typeof Menu.Item> & {
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
export function DropdownMenuItem({
|
||||
className,
|
||||
destructive,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
return (
|
||||
<Menu.Item
|
||||
className={cn(
|
||||
menuRowBaseClassName,
|
||||
menuRowStateClassName,
|
||||
destructive && 'text-text-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Menu.Separator>) {
|
||||
return (
|
||||
<Menu.Separator
|
||||
className={cn('my-1 h-px bg-divider-regular', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
web/app/components/base/ui/placement.ts
Normal file
29
web/app/components/base/ui/placement.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Placement type for overlay positioning.
|
||||
// Mirrors the Floating UI Placement spec — a stable set of 12 CSS-based position values.
|
||||
// Reference: https://floating-ui.com/docs/useFloating#placement
|
||||
|
||||
type Side = 'top' | 'bottom' | 'left' | 'right'
|
||||
type Align = 'start' | 'center' | 'end'
|
||||
|
||||
export type Placement
|
||||
= 'top'
|
||||
| 'top-start'
|
||||
| 'top-end'
|
||||
| 'right'
|
||||
| 'right-start'
|
||||
| 'right-end'
|
||||
| 'bottom'
|
||||
| 'bottom-start'
|
||||
| 'bottom-end'
|
||||
| 'left'
|
||||
| 'left-start'
|
||||
| 'left-end'
|
||||
|
||||
export function parsePlacement(placement: Placement): { side: Side, align: Align } {
|
||||
const [side, align] = placement.split('-') as [Side, Align | undefined]
|
||||
|
||||
return {
|
||||
side,
|
||||
align: align ?? 'center',
|
||||
}
|
||||
}
|
||||
107
web/app/components/base/ui/popover/__tests__/index.spec.tsx
Normal file
107
web/app/components/base/ui/popover/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Popover as BasePopover } from '@base-ui/react/popover'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from '..'
|
||||
|
||||
describe('PopoverContent', () => {
|
||||
describe('Placement', () => {
|
||||
it('should use bottom placement and default offsets when placement props are not provided', () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
|
||||
<PopoverContent
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'default positioner' }}
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'default popover' }}
|
||||
>
|
||||
<span>Default content</span>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'default positioner' })
|
||||
const popup = screen.getByRole('dialog', { name: 'default popover' })
|
||||
|
||||
expect(positioner).toHaveAttribute('data-side', 'bottom')
|
||||
expect(positioner).toHaveAttribute('data-align', 'center')
|
||||
expect(popup).toHaveTextContent('Default content')
|
||||
})
|
||||
|
||||
it('should apply parsed custom placement and custom offsets when placement props are provided', () => {
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
|
||||
<PopoverContent
|
||||
placement="top-end"
|
||||
sideOffset={14}
|
||||
alignOffset={6}
|
||||
positionerProps={{ 'role': 'group', 'aria-label': 'custom positioner' }}
|
||||
popupProps={{ 'role': 'dialog', 'aria-label': 'custom popover' }}
|
||||
>
|
||||
<span>Custom placement content</span>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'custom positioner' })
|
||||
const popup = screen.getByRole('dialog', { name: 'custom popover' })
|
||||
|
||||
expect(positioner).toHaveAttribute('data-side', 'top')
|
||||
expect(positioner).toHaveAttribute('data-align', 'end')
|
||||
expect(popup).toHaveTextContent('Custom placement content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Passthrough props', () => {
|
||||
it('should forward positionerProps and popupProps when passthrough props are provided', () => {
|
||||
const onPopupClick = vi.fn()
|
||||
|
||||
render(
|
||||
<Popover open>
|
||||
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
|
||||
<PopoverContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'popover positioner',
|
||||
'id': 'popover-positioner-id',
|
||||
}}
|
||||
popupProps={{
|
||||
'id': 'popover-popup-id',
|
||||
'role': 'dialog',
|
||||
'aria-label': 'popover content',
|
||||
'onClick': onPopupClick,
|
||||
}}
|
||||
>
|
||||
<span>Popover body</span>
|
||||
</PopoverContent>
|
||||
</Popover>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'popover positioner' })
|
||||
const popup = screen.getByRole('dialog', { name: 'popover content' })
|
||||
fireEvent.click(popup)
|
||||
|
||||
expect(positioner).toHaveAttribute('id', 'popover-positioner-id')
|
||||
expect(popup).toHaveAttribute('id', 'popover-popup-id')
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Popover aliases', () => {
|
||||
describe('Export mapping', () => {
|
||||
it('should map aliases to the matching base popover primitives when wrapper exports are imported', () => {
|
||||
expect(Popover).toBe(BasePopover.Root)
|
||||
expect(PopoverTrigger).toBe(BasePopover.Trigger)
|
||||
expect(PopoverClose).toBe(BasePopover.Close)
|
||||
expect(PopoverTitle).toBe(BasePopover.Title)
|
||||
expect(PopoverDescription).toBe(BasePopover.Description)
|
||||
})
|
||||
})
|
||||
})
|
||||
67
web/app/components/base/ui/popover/index.tsx
Normal file
67
web/app/components/base/ui/popover/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { Popover as BasePopover } from '@base-ui/react/popover'
|
||||
import * as React from 'react'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const Popover = BasePopover.Root
|
||||
export const PopoverTrigger = BasePopover.Trigger
|
||||
export const PopoverClose = BasePopover.Close
|
||||
export const PopoverTitle = BasePopover.Title
|
||||
export const PopoverDescription = BasePopover.Description
|
||||
|
||||
type PopoverContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
positionerProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BasePopover.Positioner>,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BasePopover.Popup>,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
export function PopoverContent({
|
||||
children,
|
||||
placement = 'bottom',
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
}: PopoverContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BasePopover.Portal>
|
||||
<BasePopover.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-50 outline-none', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BasePopover.Popup
|
||||
className={cn(
|
||||
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
|
||||
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</BasePopover.Popup>
|
||||
</BasePopover.Positioner>
|
||||
</BasePopover.Portal>
|
||||
)
|
||||
}
|
||||
219
web/app/components/base/ui/select/__tests__/index.spec.tsx
Normal file
219
web/app/components/base/ui/select/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index'
|
||||
|
||||
const renderOpenSelect = ({
|
||||
triggerProps = {},
|
||||
contentProps = {},
|
||||
onValueChange,
|
||||
}: {
|
||||
triggerProps?: Record<string, unknown>
|
||||
contentProps?: Record<string, unknown>
|
||||
onValueChange?: (value: string | null) => void
|
||||
} = {}) => {
|
||||
return render(
|
||||
<Select open defaultValue="seattle" onValueChange={onValueChange}>
|
||||
<SelectTrigger aria-label="city select" {...triggerProps}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'select positioner',
|
||||
}}
|
||||
popupProps={{
|
||||
'role': 'dialog',
|
||||
'aria-label': 'select popup',
|
||||
}}
|
||||
listProps={{
|
||||
'role': 'listbox',
|
||||
'aria-label': 'select list',
|
||||
}}
|
||||
{...contentProps}
|
||||
>
|
||||
<SelectItem value="seattle">Seattle</SelectItem>
|
||||
<SelectItem value="new-york">New York</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Select wrappers', () => {
|
||||
describe('SelectTrigger', () => {
|
||||
it('should render clear button when clearable is true and loading is false', () => {
|
||||
renderOpenSelect({
|
||||
triggerProps: { clearable: true },
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide clear button when loading is true', () => {
|
||||
renderOpenSelect({
|
||||
triggerProps: { clearable: true, loading: true },
|
||||
})
|
||||
|
||||
expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should forward native trigger props when trigger props are provided', () => {
|
||||
renderOpenSelect({
|
||||
triggerProps: {
|
||||
'aria-label': 'Choose option',
|
||||
'disabled': true,
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = screen.getByRole('combobox', { name: 'Choose option' })
|
||||
expect(trigger).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onClear and stop click propagation when clear button is clicked', () => {
|
||||
const onClear = vi.fn()
|
||||
const onTriggerClick = vi.fn()
|
||||
|
||||
renderOpenSelect({
|
||||
triggerProps: {
|
||||
clearable: true,
|
||||
onClear,
|
||||
onClick: onTriggerClick,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /clear selection/i }))
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
expect(onTriggerClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop mouse down propagation when clear button receives mouse down', () => {
|
||||
const onTriggerMouseDown = vi.fn()
|
||||
|
||||
renderOpenSelect({
|
||||
triggerProps: {
|
||||
clearable: true,
|
||||
onMouseDown: onTriggerMouseDown,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i }))
|
||||
|
||||
expect(onTriggerMouseDown).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not throw when clear button is clicked without onClear handler', () => {
|
||||
renderOpenSelect({
|
||||
triggerProps: { clearable: true },
|
||||
})
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear selection/i })
|
||||
expect(() => fireEvent.click(clearButton)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelectContent', () => {
|
||||
it('should use default placement when placement is not provided', () => {
|
||||
renderOpenSelect()
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'select positioner' })
|
||||
expect(positioner).toHaveAttribute('data-side', 'bottom')
|
||||
expect(positioner).toHaveAttribute('data-align', 'start')
|
||||
})
|
||||
|
||||
it('should apply custom placement when placement props are provided', () => {
|
||||
renderOpenSelect({
|
||||
contentProps: {
|
||||
placement: 'top-end',
|
||||
sideOffset: 12,
|
||||
alignOffset: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'select positioner' })
|
||||
expect(positioner).toHaveAttribute('data-side', 'top')
|
||||
expect(positioner).toHaveAttribute('data-align', 'end')
|
||||
})
|
||||
|
||||
it('should forward passthrough props to positioner popup and list when passthrough props are provided', () => {
|
||||
const onPositionerMouseEnter = vi.fn()
|
||||
const onPopupClick = vi.fn()
|
||||
const onListFocus = vi.fn()
|
||||
|
||||
render(
|
||||
<Select open defaultValue="seattle">
|
||||
<SelectTrigger aria-label="city select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
positionerProps={{
|
||||
'role': 'group',
|
||||
'aria-label': 'select positioner',
|
||||
'id': 'select-positioner',
|
||||
'onMouseEnter': onPositionerMouseEnter,
|
||||
}}
|
||||
popupProps={{
|
||||
'role': 'dialog',
|
||||
'aria-label': 'select popup',
|
||||
'id': 'select-popup',
|
||||
'onClick': onPopupClick,
|
||||
}}
|
||||
listProps={{
|
||||
'role': 'listbox',
|
||||
'aria-label': 'select list',
|
||||
'id': 'select-list',
|
||||
'onFocus': onListFocus,
|
||||
}}
|
||||
>
|
||||
<SelectItem value="seattle">Seattle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
)
|
||||
|
||||
const positioner = screen.getByRole('group', { name: 'select positioner' })
|
||||
const popup = screen.getByRole('dialog', { name: 'select popup' })
|
||||
const list = screen.getByRole('listbox', { name: 'select list' })
|
||||
|
||||
fireEvent.mouseEnter(positioner)
|
||||
fireEvent.click(popup)
|
||||
fireEvent.focus(list)
|
||||
|
||||
expect(positioner).toHaveAttribute('id', 'select-positioner')
|
||||
expect(popup).toHaveAttribute('id', 'select-popup')
|
||||
expect(list).toHaveAttribute('id', 'select-list')
|
||||
expect(onPositionerMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(onPopupClick).toHaveBeenCalledTimes(1)
|
||||
expect(onListFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelectItem', () => {
|
||||
it('should render options when children are provided', () => {
|
||||
renderOpenSelect()
|
||||
|
||||
expect(screen.getByRole('option', { name: 'Seattle' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('option', { name: 'New York' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call onValueChange when disabled item is clicked', () => {
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
render(
|
||||
<Select open defaultValue="seattle" onValueChange={onValueChange}>
|
||||
<SelectTrigger aria-label="city select">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent listProps={{ 'role': 'listbox', 'aria-label': 'select list' }}>
|
||||
<SelectItem value="seattle">Seattle</SelectItem>
|
||||
<SelectItem value="new-york" disabled aria-label="Disabled New York">
|
||||
New York
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Disabled New York' }))
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
163
web/app/components/base/ui/select/index.tsx
Normal file
163
web/app/components/base/ui/select/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { Select as BaseSelect } from '@base-ui/react/select'
|
||||
import * as React from 'react'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const Select = BaseSelect.Root
|
||||
export const SelectValue = BaseSelect.Value
|
||||
export const SelectGroup = BaseSelect.Group
|
||||
export const SelectGroupLabel = BaseSelect.GroupLabel
|
||||
export const SelectSeparator = BaseSelect.Separator
|
||||
|
||||
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
|
||||
clearable?: boolean
|
||||
onClear?: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
clearable = false,
|
||||
onClear,
|
||||
loading = false,
|
||||
...props
|
||||
}: SelectTriggerProps) {
|
||||
const showClear = clearable && !loading
|
||||
|
||||
return (
|
||||
<BaseSelect.Trigger
|
||||
className={cn(
|
||||
'group relative flex h-8 w-full items-center rounded-lg border-0 bg-components-input-bg-normal px-2 text-left text-components-input-text-filled outline-none',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="grow truncate">{children}</span>
|
||||
{loading
|
||||
? (
|
||||
<span className="ml-1 shrink-0 text-text-quaternary">
|
||||
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
)
|
||||
: showClear
|
||||
? (
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Clear selection"
|
||||
tabIndex={-1}
|
||||
className="ml-1 shrink-0 cursor-pointer text-text-quaternary hover:text-text-secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-close-circle-fill h-3.5 w-3.5" />
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<BaseSelect.Icon className="ml-1 shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-[open]:text-text-secondary">
|
||||
<span className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</BaseSelect.Icon>
|
||||
)}
|
||||
</BaseSelect.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
listClassName?: string
|
||||
positionerProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseSelect.Positioner>,
|
||||
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
|
||||
>
|
||||
popupProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseSelect.Popup>,
|
||||
'children' | 'className'
|
||||
>
|
||||
listProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof BaseSelect.List>,
|
||||
'children' | 'className'
|
||||
>
|
||||
}
|
||||
|
||||
export function SelectContent({
|
||||
children,
|
||||
placement = 'bottom-start',
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
listClassName,
|
||||
positionerProps,
|
||||
popupProps,
|
||||
listProps,
|
||||
}: SelectContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseSelect.Portal>
|
||||
<BaseSelect.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={false}
|
||||
className={cn('z-50 outline-none', className)}
|
||||
{...positionerProps}
|
||||
>
|
||||
<BaseSelect.Popup
|
||||
className={cn(
|
||||
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
|
||||
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
|
||||
popupClassName,
|
||||
)}
|
||||
{...popupProps}
|
||||
>
|
||||
<BaseSelect.List
|
||||
className={cn('max-h-80 min-w-[10rem] overflow-auto p-1 outline-none', listClassName)}
|
||||
{...listProps}
|
||||
>
|
||||
{children}
|
||||
</BaseSelect.List>
|
||||
</BaseSelect.Popup>
|
||||
</BaseSelect.Positioner>
|
||||
</BaseSelect.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof BaseSelect.Item>) {
|
||||
return (
|
||||
<BaseSelect.Item
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary outline-none system-sm-medium',
|
||||
'data-[disabled]:cursor-not-allowed data-[highlighted]:bg-state-base-hover data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BaseSelect.ItemText className="mr-1 grow truncate px-1">
|
||||
{children}
|
||||
</BaseSelect.ItemText>
|
||||
<BaseSelect.ItemIndicator className="flex shrink-0 items-center text-text-accent">
|
||||
<span className="i-ri-check-line h-4 w-4" />
|
||||
</BaseSelect.ItemIndicator>
|
||||
</BaseSelect.Item>
|
||||
)
|
||||
}
|
||||
95
web/app/components/base/ui/tooltip/__tests__/index.spec.tsx
Normal file
95
web/app/components/base/ui/tooltip/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../index'
|
||||
|
||||
describe('TooltipContent', () => {
|
||||
describe('Placement and offsets', () => {
|
||||
it('should use default top placement when placement is not provided', () => {
|
||||
render(
|
||||
<Tooltip open>
|
||||
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
|
||||
<TooltipContent role="tooltip" aria-label="default tooltip">
|
||||
Tooltip body
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
)
|
||||
|
||||
const popup = screen.getByRole('tooltip', { name: 'default tooltip' })
|
||||
expect(popup).toHaveAttribute('data-side', 'top')
|
||||
expect(popup).toHaveAttribute('data-align', 'center')
|
||||
expect(popup).toHaveTextContent('Tooltip body')
|
||||
})
|
||||
|
||||
it('should apply custom placement when placement props are provided', () => {
|
||||
render(
|
||||
<Tooltip open>
|
||||
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
|
||||
<TooltipContent
|
||||
placement="bottom-start"
|
||||
sideOffset={16}
|
||||
alignOffset={6}
|
||||
role="tooltip"
|
||||
aria-label="custom tooltip"
|
||||
>
|
||||
Custom tooltip body
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
)
|
||||
|
||||
const popup = screen.getByRole('tooltip', { name: 'custom tooltip' })
|
||||
expect(popup).toHaveAttribute('data-side', 'bottom')
|
||||
expect(popup).toHaveAttribute('data-align', 'start')
|
||||
expect(popup).toHaveTextContent('Custom tooltip body')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Variant and popup props', () => {
|
||||
it('should render popup content when variant is plain', () => {
|
||||
render(
|
||||
<Tooltip open>
|
||||
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
|
||||
<TooltipContent variant="plain" role="tooltip" aria-label="plain tooltip">
|
||||
Plain tooltip body
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('tooltip', { name: 'plain tooltip' })).toHaveTextContent('Plain tooltip body')
|
||||
})
|
||||
|
||||
it('should forward popup props and handlers when popup props are provided', () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
|
||||
render(
|
||||
<Tooltip open>
|
||||
<TooltipTrigger aria-label="tooltip trigger">Trigger</TooltipTrigger>
|
||||
<TooltipContent
|
||||
id="tooltip-popup-id"
|
||||
role="tooltip"
|
||||
aria-label="help text"
|
||||
data-track-id="tooltip-track"
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
Tooltip body
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
)
|
||||
|
||||
const popup = screen.getByRole('tooltip', { name: 'help text' })
|
||||
fireEvent.mouseEnter(popup)
|
||||
|
||||
expect(popup).toHaveAttribute('id', 'tooltip-popup-id')
|
||||
expect(popup).toHaveAttribute('data-track-id', 'tooltip-track')
|
||||
expect(onMouseEnter).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tooltip aliases', () => {
|
||||
it('should map alias exports to BaseTooltip components when wrapper exports are imported', () => {
|
||||
expect(TooltipProvider).toBe(BaseTooltip.Provider)
|
||||
expect(Tooltip).toBe(BaseTooltip.Root)
|
||||
expect(TooltipTrigger).toBe(BaseTooltip.Trigger)
|
||||
})
|
||||
})
|
||||
59
web/app/components/base/ui/tooltip/index.tsx
Normal file
59
web/app/components/base/ui/tooltip/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip'
|
||||
import * as React from 'react'
|
||||
import { parsePlacement } from '@/app/components/base/ui/placement'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type TooltipContentVariant = 'default' | 'plain'
|
||||
|
||||
export type TooltipContentProps = {
|
||||
children: React.ReactNode
|
||||
placement?: Placement
|
||||
sideOffset?: number
|
||||
alignOffset?: number
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
variant?: TooltipContentVariant
|
||||
} & Omit<React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup>, 'children' | 'className'>
|
||||
|
||||
export function TooltipContent({
|
||||
children,
|
||||
placement = 'top',
|
||||
sideOffset = 8,
|
||||
alignOffset = 0,
|
||||
className,
|
||||
popupClassName,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: TooltipContentProps) {
|
||||
const { side, align } = parsePlacement(placement)
|
||||
|
||||
return (
|
||||
<BaseTooltip.Portal>
|
||||
<BaseTooltip.Positioner
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn('z-50 outline-none', className)}
|
||||
>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
variant === 'default' && 'max-w-[300px] break-words rounded-md bg-components-panel-bg px-3 py-2 text-left text-text-tertiary shadow-lg system-xs-regular',
|
||||
'origin-[var(--transform-origin)] transition-[opacity] data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 data-[instant]:transition-none motion-reduce:transition-none',
|
||||
popupClassName,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</BaseTooltip.Popup>
|
||||
</BaseTooltip.Positioner>
|
||||
</BaseTooltip.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const TooltipProvider = BaseTooltip.Provider
|
||||
export const Tooltip = BaseTooltip.Root
|
||||
export const TooltipTrigger = BaseTooltip.Trigger
|
||||
@@ -4,7 +4,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useDocumentList } from '@/service/knowledge/use-document'
|
||||
import useDocumentsPageState from '../hooks/use-documents-page-state'
|
||||
import { useDocumentsPageState } from '../hooks/use-documents-page-state'
|
||||
import Documents from '../index'
|
||||
|
||||
// Type for mock selector function - use `as MockState` to bypass strict type checking in tests
|
||||
@@ -117,13 +117,10 @@ const mockHandleStatusFilterClear = vi.fn()
|
||||
const mockHandleSortChange = vi.fn()
|
||||
const mockHandlePageChange = vi.fn()
|
||||
const mockHandleLimitChange = vi.fn()
|
||||
const mockUpdatePollingState = vi.fn()
|
||||
const mockAdjustPageForTotal = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-documents-page-state', () => ({
|
||||
default: vi.fn(() => ({
|
||||
useDocumentsPageState: vi.fn(() => ({
|
||||
inputValue: '',
|
||||
searchValue: '',
|
||||
debouncedSearchValue: '',
|
||||
handleInputChange: mockHandleInputChange,
|
||||
statusFilterValue: 'all',
|
||||
@@ -138,9 +135,6 @@ vi.mock('../hooks/use-documents-page-state', () => ({
|
||||
handleLimitChange: mockHandleLimitChange,
|
||||
selectedIds: [] as string[],
|
||||
setSelectedIds: mockSetSelectedIds,
|
||||
timerCanRun: false,
|
||||
updatePollingState: mockUpdatePollingState,
|
||||
adjustPageForTotal: mockAdjustPageForTotal,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -319,6 +313,33 @@ describe('Documents', () => {
|
||||
expect(screen.queryByTestId('documents-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep rendering list when loading with existing data', () => {
|
||||
vi.mocked(useDocumentList).mockReturnValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
name: 'Document 1',
|
||||
indexing_status: 'completed',
|
||||
data_source_type: 'upload_file',
|
||||
position: 1,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
has_more: false,
|
||||
} as DocumentListResponse,
|
||||
isLoading: true,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useDocumentList>)
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('list-documents-count')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should render empty element when no documents exist', () => {
|
||||
vi.mocked(useDocumentList).mockReturnValueOnce({
|
||||
data: { data: [], total: 0, page: 1, limit: 10, has_more: false },
|
||||
@@ -484,17 +505,75 @@ describe('Documents', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Side Effects and Cleanup', () => {
|
||||
it('should call updatePollingState when documents response changes', () => {
|
||||
describe('Query Options', () => {
|
||||
it('should pass function refetchInterval to useDocumentList', () => {
|
||||
render(<Documents {...defaultProps} />)
|
||||
|
||||
expect(mockUpdatePollingState).toHaveBeenCalled()
|
||||
const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0]
|
||||
expect(payload).toBeDefined()
|
||||
expect(typeof payload?.refetchInterval).toBe('function')
|
||||
})
|
||||
|
||||
it('should call adjustPageForTotal when documents response changes', () => {
|
||||
it('should stop polling when all documents are in terminal statuses', () => {
|
||||
render(<Documents {...defaultProps} />)
|
||||
|
||||
expect(mockAdjustPageForTotal).toHaveBeenCalled()
|
||||
const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0]
|
||||
const refetchInterval = payload?.refetchInterval
|
||||
expect(typeof refetchInterval).toBe('function')
|
||||
if (typeof refetchInterval !== 'function')
|
||||
throw new Error('Expected function refetchInterval')
|
||||
|
||||
const interval = refetchInterval({
|
||||
state: {
|
||||
data: {
|
||||
data: [
|
||||
{ indexing_status: 'completed' },
|
||||
{ indexing_status: 'paused' },
|
||||
{ indexing_status: 'error' },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof refetchInterval>[0])
|
||||
|
||||
expect(interval).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep polling for transient status filters', () => {
|
||||
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
|
||||
inputValue: '',
|
||||
debouncedSearchValue: '',
|
||||
handleInputChange: mockHandleInputChange,
|
||||
statusFilterValue: 'indexing',
|
||||
sortValue: '-created_at' as const,
|
||||
normalizedStatusFilterValue: 'indexing',
|
||||
handleStatusFilterChange: mockHandleStatusFilterChange,
|
||||
handleStatusFilterClear: mockHandleStatusFilterClear,
|
||||
handleSortChange: mockHandleSortChange,
|
||||
currPage: 0,
|
||||
limit: 10,
|
||||
handlePageChange: mockHandlePageChange,
|
||||
handleLimitChange: mockHandleLimitChange,
|
||||
selectedIds: [] as string[],
|
||||
setSelectedIds: mockSetSelectedIds,
|
||||
})
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
|
||||
const payload = vi.mocked(useDocumentList).mock.calls.at(-1)?.[0]
|
||||
const refetchInterval = payload?.refetchInterval
|
||||
expect(typeof refetchInterval).toBe('function')
|
||||
if (typeof refetchInterval !== 'function')
|
||||
throw new Error('Expected function refetchInterval')
|
||||
|
||||
const interval = refetchInterval({
|
||||
state: {
|
||||
data: {
|
||||
data: [{ indexing_status: 'completed' }],
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof refetchInterval>[0])
|
||||
|
||||
expect(interval).toBe(2500)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -591,36 +670,6 @@ describe('Documents', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Polling State', () => {
|
||||
it('should enable polling when documents are indexing', () => {
|
||||
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
|
||||
inputValue: '',
|
||||
searchValue: '',
|
||||
debouncedSearchValue: '',
|
||||
handleInputChange: mockHandleInputChange,
|
||||
statusFilterValue: 'all',
|
||||
sortValue: '-created_at' as const,
|
||||
normalizedStatusFilterValue: 'all',
|
||||
handleStatusFilterChange: mockHandleStatusFilterChange,
|
||||
handleStatusFilterClear: mockHandleStatusFilterClear,
|
||||
handleSortChange: mockHandleSortChange,
|
||||
currPage: 0,
|
||||
limit: 10,
|
||||
handlePageChange: mockHandlePageChange,
|
||||
handleLimitChange: mockHandleLimitChange,
|
||||
selectedIds: [] as string[],
|
||||
setSelectedIds: mockSetSelectedIds,
|
||||
timerCanRun: true,
|
||||
updatePollingState: mockUpdatePollingState,
|
||||
adjustPageForTotal: mockAdjustPageForTotal,
|
||||
})
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('documents-list')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pagination', () => {
|
||||
it('should display correct total in list', () => {
|
||||
render(<Documents {...defaultProps} />)
|
||||
@@ -635,7 +684,6 @@ describe('Documents', () => {
|
||||
it('should handle page changes', () => {
|
||||
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
|
||||
inputValue: '',
|
||||
searchValue: '',
|
||||
debouncedSearchValue: '',
|
||||
handleInputChange: mockHandleInputChange,
|
||||
statusFilterValue: 'all',
|
||||
@@ -650,9 +698,6 @@ describe('Documents', () => {
|
||||
handleLimitChange: mockHandleLimitChange,
|
||||
selectedIds: [] as string[],
|
||||
setSelectedIds: mockSetSelectedIds,
|
||||
timerCanRun: false,
|
||||
updatePollingState: mockUpdatePollingState,
|
||||
adjustPageForTotal: mockAdjustPageForTotal,
|
||||
})
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
@@ -664,7 +709,6 @@ describe('Documents', () => {
|
||||
it('should display selected count', () => {
|
||||
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
|
||||
inputValue: '',
|
||||
searchValue: '',
|
||||
debouncedSearchValue: '',
|
||||
handleInputChange: mockHandleInputChange,
|
||||
statusFilterValue: 'all',
|
||||
@@ -679,9 +723,6 @@ describe('Documents', () => {
|
||||
handleLimitChange: mockHandleLimitChange,
|
||||
selectedIds: ['doc-1', 'doc-2'],
|
||||
setSelectedIds: mockSetSelectedIds,
|
||||
timerCanRun: false,
|
||||
updatePollingState: mockUpdatePollingState,
|
||||
adjustPageForTotal: mockAdjustPageForTotal,
|
||||
})
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
@@ -693,7 +734,6 @@ describe('Documents', () => {
|
||||
it('should pass filter value to list', () => {
|
||||
vi.mocked(useDocumentsPageState).mockReturnValueOnce({
|
||||
inputValue: 'test search',
|
||||
searchValue: 'test search',
|
||||
debouncedSearchValue: 'test search',
|
||||
handleInputChange: mockHandleInputChange,
|
||||
statusFilterValue: 'completed',
|
||||
@@ -708,9 +748,6 @@ describe('Documents', () => {
|
||||
handleLimitChange: mockHandleLimitChange,
|
||||
selectedIds: [] as string[],
|
||||
setSelectedIds: mockSetSelectedIds,
|
||||
timerCanRun: false,
|
||||
updatePollingState: mockUpdatePollingState,
|
||||
adjustPageForTotal: mockAdjustPageForTotal,
|
||||
})
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
|
||||
@@ -20,9 +20,8 @@ const mockHandleSave = vi.fn()
|
||||
vi.mock('../document-list/hooks', () => ({
|
||||
useDocumentSort: vi.fn(() => ({
|
||||
sortField: null,
|
||||
sortOrder: null,
|
||||
sortOrder: 'desc',
|
||||
handleSort: mockHandleSort,
|
||||
sortedDocuments: [],
|
||||
})),
|
||||
useDocumentSelection: vi.fn(() => ({
|
||||
isAllSelected: false,
|
||||
@@ -125,8 +124,8 @@ const defaultProps = {
|
||||
pagination: { total: 0, current: 1, limit: 10, onChange: vi.fn() },
|
||||
onUpdate: vi.fn(),
|
||||
onManageMetadata: vi.fn(),
|
||||
statusFilterValue: 'all',
|
||||
remoteSortValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
onSortChange: vi.fn(),
|
||||
}
|
||||
|
||||
describe('DocumentList', () => {
|
||||
@@ -140,8 +139,6 @@ describe('DocumentList', () => {
|
||||
render(<DocumentList {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('#')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-name')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-word_count')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-hit_count')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('sort-created_at')).toBeInTheDocument()
|
||||
})
|
||||
@@ -164,10 +161,9 @@ describe('DocumentList', () => {
|
||||
it('should render document rows from sortedDocuments', () => {
|
||||
const docs = [createDoc({ id: 'a', name: 'Doc A' }), createDoc({ id: 'b', name: 'Doc B' })]
|
||||
vi.mocked(useDocumentSort).mockReturnValue({
|
||||
sortField: null,
|
||||
sortField: 'created_at',
|
||||
sortOrder: 'desc',
|
||||
handleSort: mockHandleSort,
|
||||
sortedDocuments: docs,
|
||||
} as unknown as ReturnType<typeof useDocumentSort>)
|
||||
|
||||
render(<DocumentList {...defaultProps} documents={docs} />)
|
||||
@@ -182,9 +178,9 @@ describe('DocumentList', () => {
|
||||
it('should call handleSort when sort header is clicked', () => {
|
||||
render(<DocumentList {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('sort-name'))
|
||||
fireEvent.click(screen.getByTestId('sort-created_at'))
|
||||
|
||||
expect(mockHandleSort).toHaveBeenCalledWith('name')
|
||||
expect(mockHandleSort).toHaveBeenCalledWith('created_at')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -229,7 +225,6 @@ describe('DocumentList', () => {
|
||||
sortField: null,
|
||||
sortOrder: 'desc',
|
||||
handleSort: mockHandleSort,
|
||||
sortedDocuments: [],
|
||||
} as unknown as ReturnType<typeof useDocumentSort>)
|
||||
|
||||
render(<DocumentList {...defaultProps} documents={[]} />)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import type { Props as PaginationProps } from '@/app/components/base/pagination'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChunkingMode, DataSourceType } from '@/models/datasets'
|
||||
import DocumentList from '../../list'
|
||||
@@ -13,6 +13,7 @@ vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/dataset-detail', () => ({
|
||||
@@ -90,8 +91,8 @@ describe('DocumentList', () => {
|
||||
pagination: defaultPagination,
|
||||
onUpdate: vi.fn(),
|
||||
onManageMetadata: vi.fn(),
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
remoteSortValue: '-created_at',
|
||||
onSortChange: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -220,16 +221,15 @@ describe('DocumentList', () => {
|
||||
expect(sortIcons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should update sort order when sort header is clicked', () => {
|
||||
render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
|
||||
it('should call onSortChange when sortable header is clicked', () => {
|
||||
const onSortChange = vi.fn()
|
||||
const { container } = render(<DocumentList {...defaultProps} onSortChange={onSortChange} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find and click a sort header by its parent div containing the label text
|
||||
const sortableHeaders = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
if (sortableHeaders.length > 0) {
|
||||
const sortableHeaders = container.querySelectorAll('thead button')
|
||||
if (sortableHeaders.length > 0)
|
||||
fireEvent.click(sortableHeaders[0])
|
||||
}
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
expect(onSortChange).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -360,13 +360,15 @@ describe('DocumentList', () => {
|
||||
expect(modal).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show rename modal when rename button is clicked', () => {
|
||||
it('should show rename modal when rename button is clicked', async () => {
|
||||
const { container } = render(<DocumentList {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Find and click the rename button in the first row
|
||||
const renameButtons = container.querySelectorAll('.cursor-pointer.rounded-md')
|
||||
if (renameButtons.length > 0) {
|
||||
fireEvent.click(renameButtons[0])
|
||||
await act(async () => {
|
||||
fireEvent.click(renameButtons[0])
|
||||
})
|
||||
}
|
||||
|
||||
// After clicking rename, the modal should potentially be visible
|
||||
@@ -384,7 +386,7 @@ describe('DocumentList', () => {
|
||||
})
|
||||
|
||||
describe('Edit Metadata Modal', () => {
|
||||
it('should handle edit metadata action', () => {
|
||||
it('should handle edit metadata action', async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
selectedIds: ['doc-1'],
|
||||
@@ -393,7 +395,9 @@ describe('DocumentList', () => {
|
||||
|
||||
const editButton = screen.queryByRole('button', { name: /metadata/i })
|
||||
if (editButton) {
|
||||
fireEvent.click(editButton)
|
||||
await act(async () => {
|
||||
fireEvent.click(editButton)
|
||||
})
|
||||
}
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
@@ -454,16 +458,6 @@ describe('DocumentList', () => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle status filter value', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
statusFilterValue: 'completed',
|
||||
}
|
||||
render(<DocumentList {...props} />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle remote sort value', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
|
||||
@@ -7,11 +7,13 @@ import { DataSourceType } from '@/models/datasets'
|
||||
import DocumentTableRow from '../document-table-row'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
let mockSearchParams = ''
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(mockSearchParams),
|
||||
}))
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
@@ -95,6 +97,7 @@ describe('DocumentTableRow', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSearchParams = ''
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -186,6 +189,15 @@ describe('DocumentTableRow', () => {
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/custom-dataset/documents/custom-doc')
|
||||
})
|
||||
|
||||
it('should preserve search params when navigating to detail', () => {
|
||||
mockSearchParams = 'page=2&status=error'
|
||||
render(<DocumentTableRow {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
fireEvent.click(screen.getByRole('row'))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1?page=2&status=error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Word Count Display', () => {
|
||||
|
||||
@@ -4,8 +4,8 @@ import SortHeader from '../sort-header'
|
||||
|
||||
describe('SortHeader', () => {
|
||||
const defaultProps = {
|
||||
field: 'name' as const,
|
||||
label: 'File Name',
|
||||
field: 'created_at' as const,
|
||||
label: 'Upload Time',
|
||||
currentSortField: null,
|
||||
sortOrder: 'desc' as const,
|
||||
onSort: vi.fn(),
|
||||
@@ -14,12 +14,12 @@ describe('SortHeader', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render the label', () => {
|
||||
render(<SortHeader {...defaultProps} />)
|
||||
expect(screen.getByText('File Name')).toBeInTheDocument()
|
||||
expect(screen.getByText('Upload Time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the sort icon', () => {
|
||||
const { container } = render(<SortHeader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
const icon = container.querySelector('button span')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -27,13 +27,13 @@ describe('SortHeader', () => {
|
||||
describe('inactive state', () => {
|
||||
it('should have disabled text color when not active', () => {
|
||||
const { container } = render(<SortHeader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
const icon = container.querySelector('button span')
|
||||
expect(icon).toHaveClass('text-text-disabled')
|
||||
})
|
||||
|
||||
it('should not be rotated when not active', () => {
|
||||
const { container } = render(<SortHeader {...defaultProps} />)
|
||||
const icon = container.querySelector('svg')
|
||||
const icon = container.querySelector('button span')
|
||||
expect(icon).not.toHaveClass('rotate-180')
|
||||
})
|
||||
})
|
||||
@@ -41,25 +41,25 @@ describe('SortHeader', () => {
|
||||
describe('active state', () => {
|
||||
it('should have tertiary text color when active', () => {
|
||||
const { container } = render(
|
||||
<SortHeader {...defaultProps} currentSortField="name" />,
|
||||
<SortHeader {...defaultProps} currentSortField="created_at" />,
|
||||
)
|
||||
const icon = container.querySelector('svg')
|
||||
const icon = container.querySelector('button span')
|
||||
expect(icon).toHaveClass('text-text-tertiary')
|
||||
})
|
||||
|
||||
it('should not be rotated when active and desc', () => {
|
||||
const { container } = render(
|
||||
<SortHeader {...defaultProps} currentSortField="name" sortOrder="desc" />,
|
||||
<SortHeader {...defaultProps} currentSortField="created_at" sortOrder="desc" />,
|
||||
)
|
||||
const icon = container.querySelector('svg')
|
||||
const icon = container.querySelector('button span')
|
||||
expect(icon).not.toHaveClass('rotate-180')
|
||||
})
|
||||
|
||||
it('should be rotated when active and asc', () => {
|
||||
const { container } = render(
|
||||
<SortHeader {...defaultProps} currentSortField="name" sortOrder="asc" />,
|
||||
<SortHeader {...defaultProps} currentSortField="created_at" sortOrder="asc" />,
|
||||
)
|
||||
const icon = container.querySelector('svg')
|
||||
const icon = container.querySelector('button span')
|
||||
expect(icon).toHaveClass('rotate-180')
|
||||
})
|
||||
})
|
||||
@@ -69,34 +69,22 @@ describe('SortHeader', () => {
|
||||
const onSort = vi.fn()
|
||||
render(<SortHeader {...defaultProps} onSort={onSort} />)
|
||||
|
||||
fireEvent.click(screen.getByText('File Name'))
|
||||
fireEvent.click(screen.getByText('Upload Time'))
|
||||
|
||||
expect(onSort).toHaveBeenCalledWith('name')
|
||||
expect(onSort).toHaveBeenCalledWith('created_at')
|
||||
})
|
||||
|
||||
it('should call onSort with correct field', () => {
|
||||
const onSort = vi.fn()
|
||||
render(<SortHeader {...defaultProps} field="word_count" onSort={onSort} />)
|
||||
render(<SortHeader {...defaultProps} field="hit_count" onSort={onSort} />)
|
||||
|
||||
fireEvent.click(screen.getByText('File Name'))
|
||||
fireEvent.click(screen.getByText('Upload Time'))
|
||||
|
||||
expect(onSort).toHaveBeenCalledWith('word_count')
|
||||
expect(onSort).toHaveBeenCalledWith('hit_count')
|
||||
})
|
||||
})
|
||||
|
||||
describe('different fields', () => {
|
||||
it('should work with word_count field', () => {
|
||||
render(
|
||||
<SortHeader
|
||||
{...defaultProps}
|
||||
field="word_count"
|
||||
label="Words"
|
||||
currentSortField="word_count"
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Words')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should work with hit_count field', () => {
|
||||
render(
|
||||
<SortHeader
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import { pick } from 'es-toolkit/object'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -62,13 +61,15 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const isFile = doc.data_source_type === DataSourceType.FILE
|
||||
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
|
||||
const queryString = searchParams.toString()
|
||||
|
||||
const handleRowClick = useCallback(() => {
|
||||
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
|
||||
}, [router, datasetId, doc.id])
|
||||
router.push(`/datasets/${datasetId}/documents/${doc.id}${queryString ? `?${queryString}` : ''}`)
|
||||
}, [router, datasetId, doc.id, queryString])
|
||||
|
||||
const handleCheckboxClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -100,7 +101,7 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
<DocumentSourceIcon doc={doc} fileType={fileType} />
|
||||
</div>
|
||||
<Tooltip popupContent={doc.name}>
|
||||
<span className="grow-1 truncate text-sm">{doc.name}</span>
|
||||
<span className="grow truncate text-sm">{doc.name}</span>
|
||||
</Tooltip>
|
||||
{doc.summary_index_status && (
|
||||
<div className="ml-1 hidden shrink-0 group-hover:flex">
|
||||
@@ -113,7 +114,7 @@ const DocumentTableRow: FC<DocumentTableRowProps> = React.memo(({
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-state-base-hover"
|
||||
onClick={handleRenameClick}
|
||||
>
|
||||
<RiEditLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="i-ri-edit-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SortField, SortOrder } from '../hooks'
|
||||
import { RiArrowDownLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -23,19 +22,20 @@ const SortHeader: FC<SortHeaderProps> = React.memo(({
|
||||
const isDesc = isActive && sortOrder === 'desc'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center hover:text-text-secondary"
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center bg-transparent p-0 text-left hover:text-text-secondary"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label}
|
||||
<RiArrowDownLine
|
||||
<span
|
||||
className={cn(
|
||||
'ml-0.5 h-3 w-3 transition-all',
|
||||
'i-ri-arrow-down-line ml-0.5 h-3 w-3 transition-all',
|
||||
isActive ? 'text-text-tertiary' : 'text-text-disabled',
|
||||
isActive && !isDesc ? 'rotate-180' : '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,340 +1,98 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useDocumentSort } from '../use-document-sort'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
const createMockDocument = (overrides: Partial<LocalDoc> = {}): LocalDoc => ({
|
||||
id: 'doc1',
|
||||
name: 'Test Document',
|
||||
data_source_type: 'upload_file',
|
||||
data_source_info: {},
|
||||
data_source_detail_dict: {},
|
||||
word_count: 100,
|
||||
hit_count: 10,
|
||||
created_at: 1000000,
|
||||
position: 1,
|
||||
doc_form: 'text_model',
|
||||
enabled: true,
|
||||
archived: false,
|
||||
display_status: 'available',
|
||||
created_from: 'api',
|
||||
...overrides,
|
||||
} as LocalDoc)
|
||||
|
||||
describe('useDocumentSort', () => {
|
||||
describe('initial state', () => {
|
||||
it('should return null sortField initially', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
describe('remote state parsing', () => {
|
||||
it('should parse descending created_at sort', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortField).toBe('created_at')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should return documents unchanged when no sort is applied', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', name: 'B' }),
|
||||
createMockDocument({ id: 'doc2', name: 'A' }),
|
||||
]
|
||||
it('should parse ascending hit_count sort', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: 'hit_count',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
expect(result.current.sortField).toBe('hit_count')
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
})
|
||||
|
||||
expect(result.current.sortedDocuments).toEqual(docs)
|
||||
it('should fallback to inactive field for unsupported sort key', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: '-name',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleSort', () => {
|
||||
it('should set sort field when called', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should toggle sort order when same field is clicked twice', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should reset to desc when different field is selected', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('word_count')
|
||||
})
|
||||
expect(result.current.sortField).toBe('word_count')
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
|
||||
it('should not change state when null is passed', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort(null)
|
||||
})
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sorting documents', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', name: 'Banana', word_count: 200, hit_count: 5, created_at: 3000 }),
|
||||
createMockDocument({ id: 'doc2', name: 'Apple', word_count: 100, hit_count: 10, created_at: 1000 }),
|
||||
createMockDocument({ id: 'doc3', name: 'Cherry', word_count: 300, hit_count: 1, created_at: 2000 }),
|
||||
]
|
||||
|
||||
it('should sort by name descending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Cherry', 'Banana', 'Apple'])
|
||||
})
|
||||
|
||||
it('should sort by name ascending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
const names = result.current.sortedDocuments.map(d => d.name)
|
||||
expect(names).toEqual(['Apple', 'Banana', 'Cherry'])
|
||||
})
|
||||
|
||||
it('should sort by word_count descending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('word_count')
|
||||
})
|
||||
|
||||
const counts = result.current.sortedDocuments.map(d => d.word_count)
|
||||
expect(counts).toEqual([300, 200, 100])
|
||||
})
|
||||
|
||||
it('should sort by hit_count ascending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
it('should switch to desc when selecting a different field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('-hit_count')
|
||||
})
|
||||
|
||||
it('should toggle desc -> asc when clicking active field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: '-hit_count',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('hit_count')
|
||||
})
|
||||
|
||||
const counts = result.current.sortedDocuments.map(d => d.hit_count)
|
||||
expect(counts).toEqual([1, 5, 10])
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('hit_count')
|
||||
})
|
||||
|
||||
it('should sort by created_at descending', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
it('should toggle asc -> desc when clicking active field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: 'created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('created_at')
|
||||
})
|
||||
|
||||
const times = result.current.sortedDocuments.map(d => d.created_at)
|
||||
expect(times).toEqual([3000, 2000, 1000])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status filtering', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', display_status: 'available' }),
|
||||
createMockDocument({ id: 'doc2', display_status: 'error' }),
|
||||
createMockDocument({ id: 'doc3', display_status: 'available' }),
|
||||
]
|
||||
|
||||
it('should not filter when statusFilterValue is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.sortedDocuments.length).toBe(3)
|
||||
expect(onRemoteSortChange).toHaveBeenCalledWith('-created_at')
|
||||
})
|
||||
|
||||
it('should not filter when statusFilterValue is all', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: 'all',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.current.sortedDocuments.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('remoteSortValue reset', () => {
|
||||
it('should reset sort state when remoteSortValue changes', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ remoteSortValue }) =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue,
|
||||
}),
|
||||
{ initialProps: { remoteSortValue: 'initial' } },
|
||||
)
|
||||
it('should ignore null field', () => {
|
||||
const onRemoteSortChange = vi.fn()
|
||||
const { result } = renderHook(() => useDocumentSort({
|
||||
remoteSortValue: '-created_at',
|
||||
onRemoteSortChange,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
expect(result.current.sortField).toBe('name')
|
||||
expect(result.current.sortOrder).toBe('asc')
|
||||
|
||||
rerender({ remoteSortValue: 'changed' })
|
||||
|
||||
expect(result.current.sortField).toBeNull()
|
||||
expect(result.current.sortOrder).toBe('desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle documents with missing values', () => {
|
||||
const docs = [
|
||||
createMockDocument({ id: 'doc1', name: undefined as unknown as string, word_count: undefined }),
|
||||
createMockDocument({ id: 'doc2', name: 'Test', word_count: 100 }),
|
||||
]
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: docs,
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
result.current.handleSort(null)
|
||||
})
|
||||
|
||||
expect(result.current.sortedDocuments.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should handle empty documents array', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDocumentSort({
|
||||
documents: [],
|
||||
statusFilterValue: '',
|
||||
remoteSortValue: '',
|
||||
}),
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleSort('name')
|
||||
})
|
||||
|
||||
expect(result.current.sortedDocuments).toEqual([])
|
||||
expect(onRemoteSortChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,102 +1,42 @@
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
export type SortField = 'name' | 'word_count' | 'hit_count' | 'created_at' | null
|
||||
type RemoteSortField = 'hit_count' | 'created_at'
|
||||
const REMOTE_SORT_FIELDS = new Set<RemoteSortField>(['hit_count', 'created_at'])
|
||||
|
||||
export type SortField = RemoteSortField | null
|
||||
export type SortOrder = 'asc' | 'desc'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
type UseDocumentSortOptions = {
|
||||
documents: LocalDoc[]
|
||||
statusFilterValue: string
|
||||
remoteSortValue: string
|
||||
onRemoteSortChange: (nextSortValue: string) => void
|
||||
}
|
||||
|
||||
export const useDocumentSort = ({
|
||||
documents,
|
||||
statusFilterValue,
|
||||
remoteSortValue,
|
||||
onRemoteSortChange,
|
||||
}: UseDocumentSortOptions) => {
|
||||
const [sortField, setSortField] = useState<SortField>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
const prevRemoteSortValueRef = useRef(remoteSortValue)
|
||||
const sortOrder: SortOrder = remoteSortValue.startsWith('-') ? 'desc' : 'asc'
|
||||
const sortKey = remoteSortValue.startsWith('-') ? remoteSortValue.slice(1) : remoteSortValue
|
||||
|
||||
// Reset sort when remote sort changes
|
||||
if (prevRemoteSortValueRef.current !== remoteSortValue) {
|
||||
prevRemoteSortValueRef.current = remoteSortValue
|
||||
setSortField(null)
|
||||
setSortOrder('desc')
|
||||
}
|
||||
const sortField = useMemo<SortField>(() => {
|
||||
return REMOTE_SORT_FIELDS.has(sortKey as RemoteSortField) ? sortKey as RemoteSortField : null
|
||||
}, [sortKey])
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
if (field === null)
|
||||
if (!field)
|
||||
return
|
||||
|
||||
if (sortField === field) {
|
||||
setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||||
const nextSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'
|
||||
onRemoteSortChange(nextSortOrder === 'desc' ? `-${field}` : field)
|
||||
return
|
||||
}
|
||||
else {
|
||||
setSortField(field)
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}, [sortField])
|
||||
|
||||
const sortedDocuments = useMemo(() => {
|
||||
let filteredDocs = documents
|
||||
|
||||
if (statusFilterValue && statusFilterValue !== 'all') {
|
||||
filteredDocs = filteredDocs.filter(doc =>
|
||||
typeof doc.display_status === 'string'
|
||||
&& normalizeStatusForQuery(doc.display_status) === statusFilterValue,
|
||||
)
|
||||
}
|
||||
|
||||
if (!sortField)
|
||||
return filteredDocs
|
||||
|
||||
const sortedDocs = [...filteredDocs].sort((a, b) => {
|
||||
let aValue: string | number
|
||||
let bValue: string | number
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
aValue = a.name?.toLowerCase() || ''
|
||||
bValue = b.name?.toLowerCase() || ''
|
||||
break
|
||||
case 'word_count':
|
||||
aValue = a.word_count || 0
|
||||
bValue = b.word_count || 0
|
||||
break
|
||||
case 'hit_count':
|
||||
aValue = a.hit_count || 0
|
||||
bValue = b.hit_count || 0
|
||||
break
|
||||
case 'created_at':
|
||||
aValue = a.created_at
|
||||
bValue = b.created_at
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
if (sortField === 'name') {
|
||||
const result = (aValue as string).localeCompare(bValue as string)
|
||||
return sortOrder === 'asc' ? result : -result
|
||||
}
|
||||
else {
|
||||
const result = (aValue as number) - (bValue as number)
|
||||
return sortOrder === 'asc' ? result : -result
|
||||
}
|
||||
})
|
||||
|
||||
return sortedDocs
|
||||
}, [documents, sortField, sortOrder, statusFilterValue])
|
||||
onRemoteSortChange(`-${field}`)
|
||||
}, [onRemoteSortChange, sortField, sortOrder])
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortOrder,
|
||||
handleSort,
|
||||
sortedDocuments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
|
||||
import { ChunkingMode, DocumentActionType } from '@/models/datasets'
|
||||
import BatchAction from '../detail/completed/common/batch-action'
|
||||
import s from '../style.module.css'
|
||||
import { DocumentTableRow, renderTdValue, SortHeader } from './document-list/components'
|
||||
import { DocumentTableRow, SortHeader } from './document-list/components'
|
||||
import { useDocumentActions, useDocumentSelection, useDocumentSort } from './document-list/hooks'
|
||||
import RenameModal from './rename-modal'
|
||||
|
||||
@@ -29,8 +29,8 @@ type DocumentListProps = {
|
||||
pagination: PaginationProps
|
||||
onUpdate: () => void
|
||||
onManageMetadata: () => void
|
||||
statusFilterValue: string
|
||||
remoteSortValue: string
|
||||
onSortChange: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,8 +45,8 @@ const DocumentList: FC<DocumentListProps> = ({
|
||||
pagination,
|
||||
onUpdate,
|
||||
onManageMetadata,
|
||||
statusFilterValue,
|
||||
remoteSortValue,
|
||||
onSortChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const datasetConfig = useDatasetDetailContext(s => s.dataset)
|
||||
@@ -55,10 +55,9 @@ const DocumentList: FC<DocumentListProps> = ({
|
||||
const isQAMode = chunkingMode === ChunkingMode.qa
|
||||
|
||||
// Sorting
|
||||
const { sortField, sortOrder, handleSort, sortedDocuments } = useDocumentSort({
|
||||
documents,
|
||||
statusFilterValue,
|
||||
const { sortField, sortOrder, handleSort } = useDocumentSort({
|
||||
remoteSortValue,
|
||||
onRemoteSortChange: onSortChange,
|
||||
})
|
||||
|
||||
// Selection
|
||||
@@ -71,7 +70,7 @@ const DocumentList: FC<DocumentListProps> = ({
|
||||
downloadableSelectedIds,
|
||||
clearSelection,
|
||||
} = useDocumentSelection({
|
||||
documents: sortedDocuments,
|
||||
documents,
|
||||
selectedIds,
|
||||
onSelectedIdChange,
|
||||
})
|
||||
@@ -135,24 +134,10 @@ const DocumentList: FC<DocumentListProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<SortHeader
|
||||
field="name"
|
||||
label={t('list.table.header.fileName', { ns: 'datasetDocuments' })}
|
||||
currentSortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
{t('list.table.header.fileName', { ns: 'datasetDocuments' })}
|
||||
</td>
|
||||
<td className="w-[130px]">{t('list.table.header.chunkingMode', { ns: 'datasetDocuments' })}</td>
|
||||
<td className="w-24">
|
||||
<SortHeader
|
||||
field="word_count"
|
||||
label={t('list.table.header.words', { ns: 'datasetDocuments' })}
|
||||
currentSortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
</td>
|
||||
<td className="w-24">{t('list.table.header.words', { ns: 'datasetDocuments' })}</td>
|
||||
<td className="w-44">
|
||||
<SortHeader
|
||||
field="hit_count"
|
||||
@@ -176,7 +161,7 @@ const DocumentList: FC<DocumentListProps> = ({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-text-secondary">
|
||||
{sortedDocuments.map((doc, index) => (
|
||||
{documents.map((doc, index) => (
|
||||
<DocumentTableRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
@@ -248,5 +233,3 @@ const DocumentList: FC<DocumentListProps> = ({
|
||||
}
|
||||
|
||||
export default DocumentList
|
||||
|
||||
export { renderTdValue }
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => {
|
||||
documentError: null as Error | null,
|
||||
documentMetadata: null as Record<string, unknown> | null,
|
||||
media: 'desktop' as string,
|
||||
searchParams: '' as string,
|
||||
}
|
||||
return {
|
||||
state,
|
||||
@@ -26,6 +27,7 @@ const mocks = vi.hoisted(() => {
|
||||
// --- External mocks ---
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
useSearchParams: () => new URLSearchParams(mocks.state.searchParams),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
@@ -193,6 +195,7 @@ describe('DocumentDetail', () => {
|
||||
mocks.state.documentError = null
|
||||
mocks.state.documentMetadata = null
|
||||
mocks.state.media = 'desktop'
|
||||
mocks.state.searchParams = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -286,15 +289,23 @@ describe('DocumentDetail', () => {
|
||||
})
|
||||
|
||||
it('should toggle metadata panel when button clicked', () => {
|
||||
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
expect(screen.getByTestId('metadata')).toBeInTheDocument()
|
||||
|
||||
const svgs = container.querySelectorAll('svg')
|
||||
const toggleBtn = svgs[svgs.length - 1].closest('button')!
|
||||
fireEvent.click(toggleBtn)
|
||||
fireEvent.click(screen.getByTestId('document-detail-metadata-toggle'))
|
||||
expect(screen.queryByTestId('metadata')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose aria semantics for metadata toggle button', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const toggle = screen.getByTestId('document-detail-metadata-toggle')
|
||||
expect(toggle).toHaveAttribute('aria-label')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('should pass correct props to Metadata', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const metadata = screen.getByTestId('metadata')
|
||||
@@ -305,20 +316,21 @@ describe('DocumentDetail', () => {
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate back when back button clicked', () => {
|
||||
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const backBtn = container.querySelector('svg')!.parentElement!
|
||||
fireEvent.click(backBtn)
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
fireEvent.click(screen.getByTestId('document-detail-back-button'))
|
||||
expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents')
|
||||
})
|
||||
|
||||
it('should expose aria label for back button', () => {
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
expect(screen.getByTestId('document-detail-back-button')).toHaveAttribute('aria-label')
|
||||
})
|
||||
|
||||
it('should preserve query params when navigating back', () => {
|
||||
const origLocation = window.location
|
||||
window.history.pushState({}, '', '?page=2&status=active')
|
||||
const { container } = render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
const backBtn = container.querySelector('svg')!.parentElement!
|
||||
fireEvent.click(backBtn)
|
||||
mocks.state.searchParams = 'page=2&status=active'
|
||||
render(<DocumentDetail datasetId="ds-1" documentId="doc-1" />)
|
||||
fireEvent.click(screen.getByTestId('document-detail-back-button'))
|
||||
expect(mocks.push).toHaveBeenCalledWith('/datasets/ds-1/documents?page=2&status=active')
|
||||
window.history.pushState({}, '', origLocation.href)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
|
||||
import { RiArrowLeftLine, RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -35,6 +34,7 @@ type DocumentDetailProps = {
|
||||
|
||||
const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const media = useBreakpoints()
|
||||
@@ -98,11 +98,8 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
})
|
||||
|
||||
const backToPrev = () => {
|
||||
// Preserve pagination and filter states when navigating back
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const queryString = searchParams.toString()
|
||||
const separator = queryString ? '?' : ''
|
||||
const backPath = `/datasets/${datasetId}/documents${separator}${queryString}`
|
||||
const backPath = `/datasets/${datasetId}/documents${queryString ? `?${queryString}` : ''}`
|
||||
router.push(backPath)
|
||||
}
|
||||
|
||||
@@ -152,6 +149,11 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
return chunkMode === ChunkingMode.parentChild && parentMode === 'full-doc'
|
||||
}, [documentDetail?.doc_form, parentMode])
|
||||
|
||||
const backButtonLabel = t('operation.back', { ns: 'common' })
|
||||
const metadataToggleLabel = `${showMetadata
|
||||
? t('operation.close', { ns: 'common' })
|
||||
: t('operation.view', { ns: 'common' })} ${t('metadata.title', { ns: 'datasetDocuments' })}`
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={{
|
||||
datasetId,
|
||||
@@ -162,9 +164,19 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
>
|
||||
<div className="flex h-full flex-col bg-background-default">
|
||||
<div className="flex min-h-16 flex-wrap items-center justify-between border-b border-b-divider-subtle py-2.5 pl-3 pr-4">
|
||||
<div onClick={backToPrev} className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg">
|
||||
<RiArrowLeftLine className="h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="document-detail-back-button"
|
||||
aria-label={backButtonLabel}
|
||||
title={backButtonLabel}
|
||||
onClick={backToPrev}
|
||||
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full hover:bg-components-button-tertiary-bg"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-left-line h-4 w-4 text-components-button-ghost-text hover:text-text-tertiary"
|
||||
/>
|
||||
</button>
|
||||
<DocumentTitle
|
||||
datasetId={datasetId}
|
||||
extension={documentUploadFile?.extension}
|
||||
@@ -216,13 +228,17 @@ const DocumentDetail: FC<DocumentDetailProps> = ({ datasetId, documentId }) => {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="document-detail-metadata-toggle"
|
||||
aria-label={metadataToggleLabel}
|
||||
aria-pressed={showMetadata}
|
||||
title={metadataToggleLabel}
|
||||
className={style.layoutRightIcon}
|
||||
onClick={() => setShowMetadata(!showMetadata)}
|
||||
>
|
||||
{
|
||||
showMetadata
|
||||
? <RiLayoutLeft2Line className="h-4 w-4 text-components-button-secondary-text" />
|
||||
: <RiLayoutRight2Line className="h-4 w-4 text-components-button-secondary-text" />
|
||||
? <span aria-hidden="true" className="i-ri-layout-left-2-line h-4 w-4 text-components-button-secondary-text" />
|
||||
: <span aria-hidden="true" className="i-ri-layout-right-2-line h-4 w-4 text-components-button-secondary-text" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
import type { DocumentListQuery } from '../use-document-list-query-state'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import useDocumentListQueryState from '../use-document-list-query-state'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockSearchParams = new URLSearchParams()
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
usePathname: () => '/datasets/test-id/documents',
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}))
|
||||
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Reset mock search params to empty
|
||||
for (const key of [...mockSearchParams.keys()])
|
||||
mockSearchParams.delete(key)
|
||||
})
|
||||
|
||||
// Tests for parseParams (exposed via the query property)
|
||||
describe('parseParams (via query)', () => {
|
||||
it('should return default query when no search params present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse page from search params', () => {
|
||||
mockSearchParams.set('page', '3')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is zero', () => {
|
||||
mockSearchParams.set('page', '0')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is negative', () => {
|
||||
mockSearchParams.set('page', '-5')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is NaN', () => {
|
||||
mockSearchParams.set('page', 'abc')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse limit from search params', () => {
|
||||
mockSearchParams.set('limit', '50')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(50)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is zero', () => {
|
||||
mockSearchParams.set('limit', '0')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit exceeds 100', () => {
|
||||
mockSearchParams.set('limit', '101')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is negative', () => {
|
||||
mockSearchParams.set('limit', '-1')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 100', () => {
|
||||
mockSearchParams.set('limit', '100')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(100)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 1', () => {
|
||||
mockSearchParams.set('limit', '1')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.limit).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse and decode keyword from search params', () => {
|
||||
mockSearchParams.set('keyword', encodeURIComponent('hello world'))
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.keyword).toBe('hello world')
|
||||
})
|
||||
|
||||
it('should return empty keyword when not present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should sanitize status from search params', () => {
|
||||
mockSearchParams.set('status', 'available')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should fallback status to all for unknown status', () => {
|
||||
mockSearchParams.set('status', 'badvalue')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('all')
|
||||
})
|
||||
|
||||
it('should resolve active status alias to available', () => {
|
||||
mockSearchParams.set('status', 'active')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should parse valid sort value from search params', () => {
|
||||
mockSearchParams.set('sort', 'hit_count')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at for invalid sort value', () => {
|
||||
mockSearchParams.set('sort', 'invalid_sort')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at when not present', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'-created_at',
|
||||
'created_at',
|
||||
'-hit_count',
|
||||
'hit_count',
|
||||
] as const)('should accept valid sort value %s', (sortValue) => {
|
||||
mockSearchParams.set('sort', sortValue)
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current.query.sort).toBe(sortValue)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for updateQuery
|
||||
describe('updateQuery', () => {
|
||||
it('should call router.push with updated params when page is changed', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 3 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1)
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=3')
|
||||
})
|
||||
|
||||
it('should call router.push with scroll false', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
|
||||
it('should set status in URL when status is not all', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'error' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('status=error')
|
||||
})
|
||||
|
||||
it('should not set status in URL when status is all', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'all' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('status=')
|
||||
})
|
||||
|
||||
it('should set sort in URL when sort is not the default -created_at', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'hit_count' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('sort=hit_count')
|
||||
})
|
||||
|
||||
it('should not set sort in URL when sort is default -created_at', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: '-created_at' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('sort=')
|
||||
})
|
||||
|
||||
it('should encode keyword in URL when keyword is provided', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test query' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
// Source code applies encodeURIComponent before setting in URLSearchParams
|
||||
expect(pushedUrl).toContain('keyword=')
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1])
|
||||
// params.get decodes one layer, but the value was pre-encoded with encodeURIComponent
|
||||
expect(decodeURIComponent(params.get('keyword')!)).toBe('test query')
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword is empty', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('keyword=')
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all and not include in URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'invalidstatus' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('status=')
|
||||
})
|
||||
|
||||
it('should sanitize invalid sort to -created_at and not include in URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('sort=')
|
||||
})
|
||||
|
||||
it('should omit page and limit when they are default and no keyword', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 1, limit: 10 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).not.toContain('page=')
|
||||
expect(pushedUrl).not.toContain('limit=')
|
||||
})
|
||||
|
||||
it('should include page and limit when page is greater than 1', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=2')
|
||||
expect(pushedUrl).toContain('limit=10')
|
||||
})
|
||||
|
||||
it('should include page and limit when limit is non-default', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 25 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=1')
|
||||
expect(pushedUrl).toContain('limit=25')
|
||||
})
|
||||
|
||||
it('should include page and limit when keyword is provided', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'search' })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toContain('page=1')
|
||||
expect(pushedUrl).toContain('limit=10')
|
||||
})
|
||||
|
||||
it('should use pathname prefix in pushed URL', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toMatch(/^\/datasets\/test-id\/documents/)
|
||||
})
|
||||
|
||||
it('should push path without query string when all values are defaults', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({})
|
||||
})
|
||||
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
expect(pushedUrl).toBe('/datasets/test-id/documents')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for resetQuery
|
||||
describe('resetQuery', () => {
|
||||
it('should push URL with default query params when called', () => {
|
||||
mockSearchParams.set('page', '5')
|
||||
mockSearchParams.set('status', 'error')
|
||||
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledTimes(1)
|
||||
const pushedUrl = mockPush.mock.calls[0][0] as string
|
||||
// Default query has all defaults, so no params should be in the URL
|
||||
expect(pushedUrl).toBe('/datasets/test-id/documents')
|
||||
})
|
||||
|
||||
it('should call router.push with scroll false when resetting', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for return value stability
|
||||
describe('return value', () => {
|
||||
it('should return query, updateQuery, and resetQuery', () => {
|
||||
const { result } = renderHook(() => useDocumentListQueryState())
|
||||
|
||||
expect(result.current).toHaveProperty('query')
|
||||
expect(result.current).toHaveProperty('updateQuery')
|
||||
expect(result.current).toHaveProperty('resetQuery')
|
||||
expect(typeof result.current.updateQuery).toBe('function')
|
||||
expect(typeof result.current.resetQuery).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { DocumentListQuery } from '../use-document-list-query-state'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
import { useDocumentListQueryState } from '../use-document-list-query-state'
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
DisplayStatusList: [
|
||||
'queuing',
|
||||
'indexing',
|
||||
'paused',
|
||||
'error',
|
||||
'available',
|
||||
'enabled',
|
||||
'disabled',
|
||||
'archived',
|
||||
],
|
||||
}))
|
||||
|
||||
const renderWithAdapter = (searchParams = '') => {
|
||||
return renderHookWithNuqs(() => useDocumentListQueryState(), { searchParams })
|
||||
}
|
||||
|
||||
describe('useDocumentListQueryState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('query parsing', () => {
|
||||
it('should return default query when no search params present', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse page from search params', () => {
|
||||
const { result } = renderWithAdapter('?page=3')
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is zero', () => {
|
||||
const { result } = renderWithAdapter('?page=0')
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is negative', () => {
|
||||
const { result } = renderWithAdapter('?page=-5')
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should default page to 1 when page is NaN', () => {
|
||||
const { result } = renderWithAdapter('?page=abc')
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse limit from search params', () => {
|
||||
const { result } = renderWithAdapter('?limit=50')
|
||||
expect(result.current.query.limit).toBe(50)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is zero', () => {
|
||||
const { result } = renderWithAdapter('?limit=0')
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit exceeds 100', () => {
|
||||
const { result } = renderWithAdapter('?limit=101')
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should default limit to 10 when limit is negative', () => {
|
||||
const { result } = renderWithAdapter('?limit=-1')
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 100', () => {
|
||||
const { result } = renderWithAdapter('?limit=100')
|
||||
expect(result.current.query.limit).toBe(100)
|
||||
})
|
||||
|
||||
it('should accept limit at boundary 1', () => {
|
||||
const { result } = renderWithAdapter('?limit=1')
|
||||
expect(result.current.query.limit).toBe(1)
|
||||
})
|
||||
|
||||
it('should parse keyword from search params', () => {
|
||||
const { result } = renderWithAdapter('?keyword=hello+world')
|
||||
expect(result.current.query.keyword).toBe('hello world')
|
||||
})
|
||||
|
||||
it('should preserve legacy double-encoded keyword text after URL decoding', () => {
|
||||
const { result } = renderWithAdapter('?keyword=test%2520query')
|
||||
expect(result.current.query.keyword).toBe('test%20query')
|
||||
})
|
||||
|
||||
it('should return empty keyword when not present', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should sanitize status from search params', () => {
|
||||
const { result } = renderWithAdapter('?status=available')
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should fallback status to all for unknown status', () => {
|
||||
const { result } = renderWithAdapter('?status=badvalue')
|
||||
expect(result.current.query.status).toBe('all')
|
||||
})
|
||||
|
||||
it('should resolve active status alias to available', () => {
|
||||
const { result } = renderWithAdapter('?status=active')
|
||||
expect(result.current.query.status).toBe('available')
|
||||
})
|
||||
|
||||
it('should parse valid sort value from search params', () => {
|
||||
const { result } = renderWithAdapter('?sort=hit_count')
|
||||
expect(result.current.query.sort).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at for invalid sort value', () => {
|
||||
const { result } = renderWithAdapter('?sort=invalid_sort')
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it('should default sort to -created_at when not present', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
expect(result.current.query.sort).toBe('-created_at')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'-created_at',
|
||||
'created_at',
|
||||
'-hit_count',
|
||||
'hit_count',
|
||||
] as const)('should accept valid sort value %s', (sortValue) => {
|
||||
const { result } = renderWithAdapter(`?sort=${sortValue}`)
|
||||
expect(result.current.query.sort).toBe(sortValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateQuery', () => {
|
||||
it('should update page in state when page is changed', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 3 })
|
||||
})
|
||||
|
||||
expect(result.current.query.page).toBe(3)
|
||||
})
|
||||
|
||||
it('should sync page to URL with push history', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
expect(update.options.history).toBe('push')
|
||||
})
|
||||
|
||||
it('should set status in URL when status is not all', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'error' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('status')).toBe('error')
|
||||
})
|
||||
|
||||
it('should not set status in URL when status is all', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'all' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('status')).toBe(false)
|
||||
})
|
||||
|
||||
it('should set sort in URL when sort is not the default -created_at', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'hit_count' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('sort')).toBe('hit_count')
|
||||
})
|
||||
|
||||
it('should not set sort in URL when sort is default -created_at', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: '-created_at' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('sort')).toBe(false)
|
||||
})
|
||||
|
||||
it('should set keyword in URL when keyword is provided', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'test query' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('keyword')).toBe('test query')
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('should use replace history when keyword update also resets page', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?page=3')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: 'hello', page: 1 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('keyword')).toBe('hello')
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword is empty', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keyword=existing')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('keyword')).toBe(false)
|
||||
expect(update.options.history).toBe('replace')
|
||||
})
|
||||
|
||||
it('should remove keyword from URL when keyword contains only whitespace', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keyword=existing')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: ' ' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('keyword')).toBe(false)
|
||||
expect(result.current.query.keyword).toBe('')
|
||||
})
|
||||
|
||||
it('should preserve literal percent-encoded-like keyword values', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ keyword: '%2F' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('keyword')).toBe('%2F')
|
||||
expect(result.current.query.keyword).toBe('%2F')
|
||||
})
|
||||
|
||||
it('should keep keyword text unchanged when updating query from legacy URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?keyword=test%2520query')
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
expect(result.current.query.keyword).toBe('test%20query')
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all and not include in URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ status: 'invalidstatus' })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('status')).toBe(false)
|
||||
})
|
||||
|
||||
it('should sanitize invalid sort to -created_at and not include in URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ sort: 'invalidsort' as DocumentListQuery['sort'] })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('sort')).toBe(false)
|
||||
})
|
||||
|
||||
it('should not include page in URL when page is default', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 1 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
})
|
||||
|
||||
it('should include page in URL when page is greater than 1', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it('should include limit in URL when limit is non-default', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 25 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.get('limit')).toBe('25')
|
||||
})
|
||||
|
||||
it('should sanitize invalid page to default and omit page from URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ page: -1 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
expect(result.current.query.page).toBe(1)
|
||||
})
|
||||
|
||||
it('should sanitize invalid limit to default and omit limit from URL', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter()
|
||||
|
||||
act(() => {
|
||||
result.current.updateQuery({ limit: 999 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('limit')).toBe(false)
|
||||
expect(result.current.query.limit).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetQuery', () => {
|
||||
it('should reset all values to defaults', () => {
|
||||
const { result } = renderWithAdapter('?page=5&status=error&sort=hit_count')
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
expect(result.current.query).toEqual({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
})
|
||||
})
|
||||
|
||||
it('should clear all params from URL when called', async () => {
|
||||
const { result, onUrlUpdate } = renderWithAdapter('?page=5&status=error')
|
||||
|
||||
act(() => {
|
||||
result.current.resetQuery()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(update.searchParams.has('page')).toBe(false)
|
||||
expect(update.searchParams.has('status')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('return value', () => {
|
||||
it('should return query, updateQuery, and resetQuery', () => {
|
||||
const { result } = renderWithAdapter()
|
||||
|
||||
expect(result.current).toHaveProperty('query')
|
||||
expect(result.current).toHaveProperty('updateQuery')
|
||||
expect(result.current).toHaveProperty('resetQuery')
|
||||
expect(typeof result.current.updateQuery).toBe('function')
|
||||
expect(typeof result.current.resetQuery).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { DocumentListQuery } from '../use-document-list-query-state'
|
||||
import type { DocumentListResponse } from '@/models/datasets'
|
||||
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDocumentsPageState } from '../use-documents-page-state'
|
||||
|
||||
const mockUpdateQuery = vi.fn()
|
||||
const mockResetQuery = vi.fn()
|
||||
let mockQuery: DocumentListQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' }
|
||||
|
||||
vi.mock('@/models/datasets', () => ({
|
||||
@@ -22,151 +20,70 @@ vi.mock('@/models/datasets', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
usePathname: () => '/datasets/test-id/documents',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
// Mock ahooks debounce utilities: required because tests capture the debounce
|
||||
// callback reference to invoke it synchronously, bypassing real timer delays.
|
||||
let capturedDebounceFnCallback: (() => void) | null = null
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounce: (value: unknown, _options?: { wait?: number }) => value,
|
||||
useDebounceFn: (fn: () => void, _options?: { wait?: number }) => {
|
||||
capturedDebounceFnCallback = fn
|
||||
return { run: fn, cancel: vi.fn(), flush: vi.fn() }
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock the dependent hook
|
||||
vi.mock('../use-document-list-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQuery,
|
||||
updateQuery: mockUpdateQuery,
|
||||
resetQuery: mockResetQuery,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Factory for creating DocumentListResponse test data
|
||||
function createDocumentListResponse(overrides: Partial<DocumentListResponse> = {}): DocumentListResponse {
|
||||
vi.mock('../use-document-list-query-state', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
data: [],
|
||||
has_more: false,
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
...overrides,
|
||||
useDocumentListQueryState: () => {
|
||||
const [query, setQuery] = React.useState<DocumentListQuery>(mockQuery)
|
||||
return {
|
||||
query,
|
||||
updateQuery: (updates: Partial<DocumentListQuery>) => {
|
||||
mockUpdateQuery(updates)
|
||||
setQuery(prev => ({ ...prev, ...updates }))
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Factory for creating a minimal document item
|
||||
function createDocumentItem(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: `doc-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: 'test-doc.txt',
|
||||
indexing_status: 'completed' as string,
|
||||
display_status: 'available' as string,
|
||||
enabled: true,
|
||||
archived: false,
|
||||
word_count: 100,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
created_from: 'web' as const,
|
||||
created_by: 'user-1',
|
||||
dataset_process_rule_id: 'rule-1',
|
||||
doc_form: 'text_model' as const,
|
||||
doc_language: 'en',
|
||||
position: 1,
|
||||
data_source_type: 'upload_file',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('useDocumentsPageState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedDebounceFnCallback = null
|
||||
mockQuery = { page: 1, limit: 10, keyword: '', status: 'all', sort: '-created_at' }
|
||||
})
|
||||
|
||||
// Initial state verification
|
||||
describe('initial state', () => {
|
||||
it('should return correct initial search state', () => {
|
||||
it('should return correct initial query-derived state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.inputValue).toBe('')
|
||||
expect(result.current.searchValue).toBe('')
|
||||
expect(result.current.debouncedSearchValue).toBe('')
|
||||
})
|
||||
|
||||
it('should return correct initial filter and sort state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(result.current.sortValue).toBe('-created_at')
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('all')
|
||||
})
|
||||
|
||||
it('should return correct initial pagination state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// page is query.page - 1 = 0
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(result.current.limit).toBe(10)
|
||||
})
|
||||
|
||||
it('should return correct initial selection state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.selectedIds).toEqual([])
|
||||
})
|
||||
|
||||
it('should return correct initial polling state', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should initialize from query when query has keyword', () => {
|
||||
mockQuery = { ...mockQuery, keyword: 'initial search' }
|
||||
it('should initialize from non-default query values', () => {
|
||||
mockQuery = {
|
||||
page: 3,
|
||||
limit: 25,
|
||||
keyword: 'initial',
|
||||
status: 'enabled',
|
||||
sort: 'hit_count',
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.inputValue).toBe('initial search')
|
||||
expect(result.current.searchValue).toBe('initial search')
|
||||
})
|
||||
|
||||
it('should initialize pagination from query with non-default page', () => {
|
||||
mockQuery = { ...mockQuery, page: 3, limit: 25 }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.currPage).toBe(2) // page - 1
|
||||
expect(result.current.inputValue).toBe('initial')
|
||||
expect(result.current.currPage).toBe(2)
|
||||
expect(result.current.limit).toBe(25)
|
||||
})
|
||||
|
||||
it('should initialize status filter from query', () => {
|
||||
mockQuery = { ...mockQuery, status: 'error' }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('error')
|
||||
})
|
||||
|
||||
it('should initialize sort from query', () => {
|
||||
mockQuery = { ...mockQuery, sort: 'hit_count' }
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('enabled')
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('available')
|
||||
expect(result.current.sortValue).toBe('hit_count')
|
||||
})
|
||||
})
|
||||
|
||||
// Handler behaviors
|
||||
describe('handleInputChange', () => {
|
||||
it('should update input value when called', () => {
|
||||
it('should update keyword and reset page', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -174,30 +91,59 @@ describe('useDocumentsPageState', () => {
|
||||
})
|
||||
|
||||
expect(result.current.inputValue).toBe('new value')
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ keyword: 'new value', page: 1 })
|
||||
})
|
||||
|
||||
it('should trigger debounced search callback when called', () => {
|
||||
it('should clear selected ids when keyword changes', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// First call sets inputValue and triggers the debounced fn
|
||||
act(() => {
|
||||
result.current.handleInputChange('search term')
|
||||
result.current.setSelectedIds(['doc-1'])
|
||||
})
|
||||
expect(result.current.selectedIds).toEqual(['doc-1'])
|
||||
|
||||
act(() => {
|
||||
result.current.handleInputChange('keyword')
|
||||
})
|
||||
|
||||
// The debounced fn captures inputValue from its render closure.
|
||||
// After re-render with new inputValue, calling the captured callback again
|
||||
// should reflect the updated state.
|
||||
expect(result.current.selectedIds).toEqual([])
|
||||
})
|
||||
|
||||
it('should keep selected ids when keyword is unchanged', () => {
|
||||
mockQuery = { ...mockQuery, keyword: 'same' }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
if (capturedDebounceFnCallback)
|
||||
capturedDebounceFnCallback()
|
||||
result.current.setSelectedIds(['doc-1'])
|
||||
})
|
||||
|
||||
expect(result.current.searchValue).toBe('search term')
|
||||
act(() => {
|
||||
result.current.handleInputChange('same')
|
||||
})
|
||||
|
||||
expect(result.current.selectedIds).toEqual(['doc-1'])
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ keyword: 'same', page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleStatusFilterChange', () => {
|
||||
it('should update status filter value when called with valid status', () => {
|
||||
it('should sanitize status, reset page, and clear selection', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedIds(['doc-1'])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('invalid')
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(result.current.selectedIds).toEqual([])
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
|
||||
})
|
||||
|
||||
it('should update to valid status value', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -205,61 +151,23 @@ describe('useDocumentsPageState', () => {
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('error')
|
||||
})
|
||||
|
||||
it('should reset page to 0 when status filter changes', () => {
|
||||
mockQuery = { ...mockQuery, page: 3 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should call updateQuery with sanitized status and page 1', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'error', page: 1 })
|
||||
})
|
||||
|
||||
it('should sanitize invalid status to all', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('invalid')
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleStatusFilterClear', () => {
|
||||
it('should set status to all and reset page when status is not all', () => {
|
||||
it('should reset status to all when status is not all', () => {
|
||||
mockQuery = { ...mockQuery, status: 'error' }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// First set a non-all status
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Then clear
|
||||
act(() => {
|
||||
result.current.handleStatusFilterClear()
|
||||
})
|
||||
|
||||
expect(result.current.statusFilterValue).toBe('all')
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ status: 'all', page: 1 })
|
||||
})
|
||||
|
||||
it('should not call updateQuery when status is already all', () => {
|
||||
it('should do nothing when status is already all', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -271,7 +179,7 @@ describe('useDocumentsPageState', () => {
|
||||
})
|
||||
|
||||
describe('handleSortChange', () => {
|
||||
it('should update sort value and call updateQuery when value changes', () => {
|
||||
it('should update sort and reset page when sort changes', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -282,18 +190,7 @@ describe('useDocumentsPageState', () => {
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ sort: 'hit_count', page: 1 })
|
||||
})
|
||||
|
||||
it('should reset page to 0 when sort changes', () => {
|
||||
mockQuery = { ...mockQuery, page: 5 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSortChange('hit_count')
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should not call updateQuery when sort value is same as current', () => {
|
||||
it('should ignore sort update when value is unchanged', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -304,8 +201,8 @@ describe('useDocumentsPageState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handlePageChange', () => {
|
||||
it('should update current page and call updateQuery', () => {
|
||||
describe('pagination handlers', () => {
|
||||
it('should update page with one-based value', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -313,23 +210,10 @@ describe('useDocumentsPageState', () => {
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(2)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // newPage + 1
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 })
|
||||
})
|
||||
|
||||
it('should handle page 0 (first page)', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handlePageChange(0)
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLimitChange', () => {
|
||||
it('should update limit, reset page to 0, and call updateQuery', () => {
|
||||
it('should update limit and reset page', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
@@ -342,359 +226,29 @@ describe('useDocumentsPageState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Selection state
|
||||
describe('selection state', () => {
|
||||
it('should update selectedIds via setSelectedIds', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.setSelectedIds(['doc-1', 'doc-2'])
|
||||
})
|
||||
|
||||
expect(result.current.selectedIds).toEqual(['doc-1', 'doc-2'])
|
||||
})
|
||||
})
|
||||
|
||||
// Polling state management
|
||||
describe('updatePollingState', () => {
|
||||
it('should not update timer when documentsRes is undefined', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(undefined)
|
||||
})
|
||||
|
||||
// timerCanRun remains true (initial value)
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should not update timer when documentsRes.data is undefined', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState({ data: undefined } as unknown as DocumentListResponse)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should set timerCanRun to false when all documents are completed and status filter is all', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should set timerCanRun to true when some documents are not completed', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
createDocumentItem({ indexing_status: 'indexing' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should count paused documents as completed for polling purposes', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'paused' }),
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// All docs are "embedded" (completed, paused, error), so hasIncomplete = false
|
||||
// statusFilter is 'all', so shouldForcePolling = false
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should count error documents as completed for polling purposes', () => {
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'error' }),
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 2,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should force polling when status filter is a transient status (queuing)', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// Set status filter to queuing
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('queuing')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// shouldForcePolling = true (queuing is transient), hasIncomplete = false
|
||||
// timerCanRun = true || false = true
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should force polling when status filter is indexing', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('indexing')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'completed' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should force polling when status filter is paused', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('paused')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'paused' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
|
||||
it('should not force polling when status filter is a non-transient status (error)', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({
|
||||
data: [
|
||||
createDocumentItem({ indexing_status: 'error' }),
|
||||
] as DocumentListResponse['data'],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// shouldForcePolling = false (error is not transient), hasIncomplete = false (error is embedded)
|
||||
expect(result.current.timerCanRun).toBe(false)
|
||||
})
|
||||
|
||||
it('should set timerCanRun to true when data is empty and filter is transient', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('indexing')
|
||||
})
|
||||
|
||||
const response = createDocumentListResponse({ data: [] as DocumentListResponse['data'], total: 0 })
|
||||
|
||||
act(() => {
|
||||
result.current.updatePollingState(response)
|
||||
})
|
||||
|
||||
// shouldForcePolling = true (indexing is transient), hasIncomplete = false (0 !== 0 is false)
|
||||
expect(result.current.timerCanRun).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Page adjustment
|
||||
describe('adjustPageForTotal', () => {
|
||||
it('should not adjust page when documentsRes is undefined', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(undefined)
|
||||
})
|
||||
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should not adjust page when currPage is within total pages', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
const response = createDocumentListResponse({ total: 20 })
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// currPage is 0, totalPages is 2, so no adjustment needed
|
||||
expect(result.current.currPage).toBe(0)
|
||||
})
|
||||
|
||||
it('should adjust page to last page when currPage exceeds total pages', () => {
|
||||
mockQuery = { ...mockQuery, page: 6 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// currPage should be 5 (page - 1)
|
||||
expect(result.current.currPage).toBe(5)
|
||||
|
||||
const response = createDocumentListResponse({ total: 30 }) // 30/10 = 3 pages
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// currPage (5) + 1 > totalPages (3), so adjust to totalPages - 1 = 2
|
||||
expect(result.current.currPage).toBe(2)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 3 }) // handlePageChange passes newPage + 1
|
||||
})
|
||||
|
||||
it('should adjust page to 0 when total is 0 and currPage > 0', () => {
|
||||
mockQuery = { ...mockQuery, page: 3 }
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
const response = createDocumentListResponse({ total: 0 })
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// totalPages = 0, so adjust to max(0 - 1, 0) = 0
|
||||
expect(result.current.currPage).toBe(0)
|
||||
expect(mockUpdateQuery).toHaveBeenCalledWith({ page: 1 })
|
||||
})
|
||||
|
||||
it('should not adjust page when currPage is 0 even if total is 0', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
const response = createDocumentListResponse({ total: 0 })
|
||||
|
||||
act(() => {
|
||||
result.current.adjustPageForTotal(response)
|
||||
})
|
||||
|
||||
// currPage is 0, condition is currPage > 0 so no adjustment
|
||||
expect(mockUpdateQuery).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Normalized status filter value
|
||||
describe('normalizedStatusFilterValue', () => {
|
||||
it('should return all for default status', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('all')
|
||||
})
|
||||
|
||||
it('should normalize enabled to available', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('enabled')
|
||||
})
|
||||
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('available')
|
||||
})
|
||||
|
||||
it('should return non-aliased status as-is', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
act(() => {
|
||||
result.current.handleStatusFilterChange('error')
|
||||
})
|
||||
|
||||
expect(result.current.normalizedStatusFilterValue).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
// Return value shape
|
||||
describe('return value', () => {
|
||||
it('should return all expected properties', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
// Search state
|
||||
expect(result.current).toHaveProperty('inputValue')
|
||||
expect(result.current).toHaveProperty('searchValue')
|
||||
expect(result.current).toHaveProperty('debouncedSearchValue')
|
||||
expect(result.current).toHaveProperty('handleInputChange')
|
||||
|
||||
// Filter & sort state
|
||||
expect(result.current).toHaveProperty('statusFilterValue')
|
||||
expect(result.current).toHaveProperty('sortValue')
|
||||
expect(result.current).toHaveProperty('normalizedStatusFilterValue')
|
||||
expect(result.current).toHaveProperty('handleStatusFilterChange')
|
||||
expect(result.current).toHaveProperty('handleStatusFilterClear')
|
||||
expect(result.current).toHaveProperty('handleSortChange')
|
||||
|
||||
// Pagination state
|
||||
expect(result.current).toHaveProperty('currPage')
|
||||
expect(result.current).toHaveProperty('limit')
|
||||
expect(result.current).toHaveProperty('handlePageChange')
|
||||
expect(result.current).toHaveProperty('handleLimitChange')
|
||||
|
||||
// Selection state
|
||||
expect(result.current).toHaveProperty('selectedIds')
|
||||
expect(result.current).toHaveProperty('setSelectedIds')
|
||||
|
||||
// Polling state
|
||||
expect(result.current).toHaveProperty('timerCanRun')
|
||||
expect(result.current).toHaveProperty('updatePollingState')
|
||||
expect(result.current).toHaveProperty('adjustPageForTotal')
|
||||
})
|
||||
|
||||
it('should have function types for all handlers', () => {
|
||||
it('should expose function handlers', () => {
|
||||
const { result } = renderHook(() => useDocumentsPageState())
|
||||
|
||||
expect(typeof result.current.handleInputChange).toBe('function')
|
||||
@@ -704,8 +258,6 @@ describe('useDocumentsPageState', () => {
|
||||
expect(typeof result.current.handlePageChange).toBe('function')
|
||||
expect(typeof result.current.handleLimitChange).toBe('function')
|
||||
expect(typeof result.current.setSelectedIds).toBe('function')
|
||||
expect(typeof result.current.updatePollingState).toBe('function')
|
||||
expect(typeof result.current.adjustPageForTotal).toBe('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReadonlyURLSearchParams } from 'next/navigation'
|
||||
import type { inferParserType } from 'nuqs'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createParser, parseAsString, throttle, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { sanitizeStatusValue } from '../status-filter'
|
||||
|
||||
@@ -13,99 +13,87 @@ const sanitizeSortValue = (value?: string | null): SortType => {
|
||||
return (ALLOWED_SORT_VALUES.includes(value as SortType) ? value : '-created_at') as SortType
|
||||
}
|
||||
|
||||
export type DocumentListQuery = {
|
||||
page: number
|
||||
limit: number
|
||||
keyword: string
|
||||
status: string
|
||||
sort: SortType
|
||||
const sanitizePageValue = (value: number): number => {
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY: DocumentListQuery = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
status: 'all',
|
||||
sort: '-created_at',
|
||||
const sanitizeLimitValue = (value: number): number => {
|
||||
return Number.isInteger(value) && value > 0 && value <= 100 ? value : 10
|
||||
}
|
||||
|
||||
// Parse the query parameters from the URL search string.
|
||||
function parseParams(params: ReadonlyURLSearchParams): DocumentListQuery {
|
||||
const page = Number.parseInt(params.get('page') || '1', 10)
|
||||
const limit = Number.parseInt(params.get('limit') || '10', 10)
|
||||
const keyword = params.get('keyword') || ''
|
||||
const status = sanitizeStatusValue(params.get('status'))
|
||||
const sort = sanitizeSortValue(params.get('sort'))
|
||||
const parseAsPage = createParser<number>({
|
||||
parse: (value) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isNaN(n) || n <= 0 ? null : n
|
||||
},
|
||||
serialize: value => value.toString(),
|
||||
}).withDefault(1)
|
||||
|
||||
return {
|
||||
page: page > 0 ? page : 1,
|
||||
limit: (limit > 0 && limit <= 100) ? limit : 10,
|
||||
keyword: keyword ? decodeURIComponent(keyword) : '',
|
||||
status,
|
||||
sort,
|
||||
}
|
||||
const parseAsLimit = createParser<number>({
|
||||
parse: (value) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isNaN(n) || n <= 0 || n > 100 ? null : n
|
||||
},
|
||||
serialize: value => value.toString(),
|
||||
}).withDefault(10)
|
||||
|
||||
const parseAsDocStatus = createParser<string>({
|
||||
parse: value => sanitizeStatusValue(value),
|
||||
serialize: value => value,
|
||||
}).withDefault('all')
|
||||
|
||||
const parseAsDocSort = createParser<SortType>({
|
||||
parse: value => sanitizeSortValue(value),
|
||||
serialize: value => value,
|
||||
}).withDefault('-created_at' as SortType)
|
||||
|
||||
const parseAsKeyword = parseAsString.withDefault('')
|
||||
|
||||
export const documentListParsers = {
|
||||
page: parseAsPage,
|
||||
limit: parseAsLimit,
|
||||
keyword: parseAsKeyword,
|
||||
status: parseAsDocStatus,
|
||||
sort: parseAsDocSort,
|
||||
}
|
||||
|
||||
// Update the URL search string with the given query parameters.
|
||||
function updateSearchParams(query: DocumentListQuery, searchParams: URLSearchParams) {
|
||||
const { page, limit, keyword, status, sort } = query || {}
|
||||
export type DocumentListQuery = inferParserType<typeof documentListParsers>
|
||||
|
||||
const hasNonDefaultParams = (page && page > 1) || (limit && limit !== 10) || (keyword && keyword.trim())
|
||||
// Search input updates can be frequent; throttle URL writes to reduce history/api churn.
|
||||
const KEYWORD_URL_UPDATE_THROTTLE = throttle(300)
|
||||
|
||||
if (hasNonDefaultParams) {
|
||||
searchParams.set('page', (page || 1).toString())
|
||||
searchParams.set('limit', (limit || 10).toString())
|
||||
}
|
||||
else {
|
||||
searchParams.delete('page')
|
||||
searchParams.delete('limit')
|
||||
}
|
||||
export function useDocumentListQueryState() {
|
||||
const [query, setQuery] = useQueryStates(documentListParsers)
|
||||
|
||||
if (keyword && keyword.trim())
|
||||
searchParams.set('keyword', encodeURIComponent(keyword))
|
||||
else
|
||||
searchParams.delete('keyword')
|
||||
|
||||
const sanitizedStatus = sanitizeStatusValue(status)
|
||||
if (sanitizedStatus && sanitizedStatus !== 'all')
|
||||
searchParams.set('status', sanitizedStatus)
|
||||
else
|
||||
searchParams.delete('status')
|
||||
|
||||
const sanitizedSort = sanitizeSortValue(sort)
|
||||
if (sanitizedSort !== '-created_at')
|
||||
searchParams.set('sort', sanitizedSort)
|
||||
else
|
||||
searchParams.delete('sort')
|
||||
}
|
||||
|
||||
function useDocumentListQueryState() {
|
||||
const searchParams = useSearchParams()
|
||||
const query = useMemo(() => parseParams(searchParams), [searchParams])
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
// Helper function to update specific query parameters
|
||||
const updateQuery = useCallback((updates: Partial<DocumentListQuery>) => {
|
||||
const newQuery = { ...query, ...updates }
|
||||
newQuery.status = sanitizeStatusValue(newQuery.status)
|
||||
newQuery.sort = sanitizeSortValue(newQuery.sort)
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(newQuery, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [query, router, pathname])
|
||||
const patch = { ...updates }
|
||||
if ('page' in patch && patch.page !== undefined)
|
||||
patch.page = sanitizePageValue(patch.page)
|
||||
if ('limit' in patch && patch.limit !== undefined)
|
||||
patch.limit = sanitizeLimitValue(patch.limit)
|
||||
if ('status' in patch)
|
||||
patch.status = sanitizeStatusValue(patch.status)
|
||||
if ('sort' in patch)
|
||||
patch.sort = sanitizeSortValue(patch.sort)
|
||||
if ('keyword' in patch && typeof patch.keyword === 'string' && patch.keyword.trim() === '')
|
||||
patch.keyword = ''
|
||||
|
||||
// If keyword is part of this patch (even with page reset), treat it as a search update:
|
||||
// use replace to avoid creating a history entry per input-driven change.
|
||||
if ('keyword' in patch) {
|
||||
setQuery(patch, {
|
||||
history: 'replace',
|
||||
limitUrlUpdates: patch.keyword === '' ? undefined : KEYWORD_URL_UPDATE_THROTTLE,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setQuery(patch, { history: 'push' })
|
||||
}, [setQuery])
|
||||
|
||||
// Helper function to reset query to defaults
|
||||
const resetQuery = useCallback(() => {
|
||||
const params = new URLSearchParams()
|
||||
updateSearchParams(DEFAULT_QUERY, params)
|
||||
const search = params.toString()
|
||||
const queryString = search ? `?${search}` : ''
|
||||
router.push(`${pathname}${queryString}`, { scroll: false })
|
||||
}, [router, pathname])
|
||||
setQuery(null, { history: 'replace' })
|
||||
}, [setQuery])
|
||||
|
||||
return useMemo(() => ({
|
||||
query,
|
||||
@@ -113,5 +101,3 @@ function useDocumentListQueryState() {
|
||||
resetQuery,
|
||||
}), [query, updateQuery, resetQuery])
|
||||
}
|
||||
|
||||
export default useDocumentListQueryState
|
||||
|
||||
@@ -1,175 +1,63 @@
|
||||
import type { DocumentListResponse } from '@/models/datasets'
|
||||
import type { SortType } from '@/service/datasets'
|
||||
import { useDebounce, useDebounceFn } from 'ahooks'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter'
|
||||
import useDocumentListQueryState from './use-document-list-query-state'
|
||||
import { useDocumentListQueryState } from './use-document-list-query-state'
|
||||
|
||||
/**
|
||||
* Custom hook to manage documents page state including:
|
||||
* - Search state (input value, debounced search value)
|
||||
* - Filter state (status filter, sort value)
|
||||
* - Pagination state (current page, limit)
|
||||
* - Selection state (selected document ids)
|
||||
* - Polling state (timer control for auto-refresh)
|
||||
*/
|
||||
export function useDocumentsPageState() {
|
||||
const { query, updateQuery } = useDocumentListQueryState()
|
||||
|
||||
// Search state
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const debouncedSearchValue = useDebounce(searchValue, { wait: 500 })
|
||||
const inputValue = query.keyword
|
||||
const debouncedSearchValue = useDebounce(query.keyword, { wait: 500 })
|
||||
|
||||
// Filter & sort state
|
||||
const [statusFilterValue, setStatusFilterValue] = useState<string>(() => sanitizeStatusValue(query.status))
|
||||
const [sortValue, setSortValue] = useState<SortType>(query.sort)
|
||||
const normalizedStatusFilterValue = useMemo(
|
||||
() => normalizeStatusForQuery(statusFilterValue),
|
||||
[statusFilterValue],
|
||||
)
|
||||
const statusFilterValue = sanitizeStatusValue(query.status)
|
||||
const sortValue = query.sort
|
||||
const normalizedStatusFilterValue = normalizeStatusForQuery(statusFilterValue)
|
||||
|
||||
// Pagination state
|
||||
const [currPage, setCurrPage] = useState<number>(query.page - 1)
|
||||
const [limit, setLimit] = useState<number>(query.limit)
|
||||
const currPage = query.page - 1
|
||||
const limit = query.limit
|
||||
|
||||
// Selection state
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
|
||||
// Polling state
|
||||
const [timerCanRun, setTimerCanRun] = useState(true)
|
||||
|
||||
// Initialize search value from URL on mount
|
||||
useEffect(() => {
|
||||
if (query.keyword) {
|
||||
setInputValue(query.keyword)
|
||||
setSearchValue(query.keyword)
|
||||
}
|
||||
}, []) // Only run on mount
|
||||
|
||||
// Sync local state with URL query changes
|
||||
useEffect(() => {
|
||||
setCurrPage(query.page - 1)
|
||||
setLimit(query.limit)
|
||||
if (query.keyword !== searchValue) {
|
||||
setInputValue(query.keyword)
|
||||
setSearchValue(query.keyword)
|
||||
}
|
||||
setStatusFilterValue((prev) => {
|
||||
const nextValue = sanitizeStatusValue(query.status)
|
||||
return prev === nextValue ? prev : nextValue
|
||||
})
|
||||
setSortValue(query.sort)
|
||||
}, [query])
|
||||
|
||||
// Update URL when search changes
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue !== query.keyword) {
|
||||
setCurrPage(0)
|
||||
updateQuery({ keyword: debouncedSearchValue, page: 1 })
|
||||
}
|
||||
}, [debouncedSearchValue, query.keyword, updateQuery])
|
||||
|
||||
// Clear selection when search changes
|
||||
useEffect(() => {
|
||||
if (searchValue !== query.keyword)
|
||||
setSelectedIds([])
|
||||
}, [searchValue, query.keyword])
|
||||
|
||||
// Clear selection when status filter changes
|
||||
useEffect(() => {
|
||||
setSelectedIds([])
|
||||
}, [normalizedStatusFilterValue])
|
||||
|
||||
// Page change handler
|
||||
const handlePageChange = useCallback((newPage: number) => {
|
||||
setCurrPage(newPage)
|
||||
updateQuery({ page: newPage + 1 })
|
||||
}, [updateQuery])
|
||||
|
||||
// Limit change handler
|
||||
const handleLimitChange = useCallback((newLimit: number) => {
|
||||
setLimit(newLimit)
|
||||
setCurrPage(0)
|
||||
updateQuery({ limit: newLimit, page: 1 })
|
||||
}, [updateQuery])
|
||||
|
||||
// Debounced search handler
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchValue(inputValue)
|
||||
}, { wait: 500 })
|
||||
|
||||
// Input change handler
|
||||
const handleInputChange = useCallback((value: string) => {
|
||||
setInputValue(value)
|
||||
handleSearch()
|
||||
}, [handleSearch])
|
||||
if (value !== query.keyword)
|
||||
setSelectedIds([])
|
||||
updateQuery({ keyword: value, page: 1 })
|
||||
}, [query.keyword, updateQuery])
|
||||
|
||||
// Status filter change handler
|
||||
const handleStatusFilterChange = useCallback((value: string) => {
|
||||
const selectedValue = sanitizeStatusValue(value)
|
||||
setStatusFilterValue(selectedValue)
|
||||
setCurrPage(0)
|
||||
setSelectedIds([])
|
||||
updateQuery({ status: selectedValue, page: 1 })
|
||||
}, [updateQuery])
|
||||
|
||||
// Status filter clear handler
|
||||
const handleStatusFilterClear = useCallback(() => {
|
||||
if (statusFilterValue === 'all')
|
||||
return
|
||||
setStatusFilterValue('all')
|
||||
setCurrPage(0)
|
||||
setSelectedIds([])
|
||||
updateQuery({ status: 'all', page: 1 })
|
||||
}, [statusFilterValue, updateQuery])
|
||||
|
||||
// Sort change handler
|
||||
const handleSortChange = useCallback((value: string) => {
|
||||
const next = value as SortType
|
||||
if (next === sortValue)
|
||||
return
|
||||
setSortValue(next)
|
||||
setCurrPage(0)
|
||||
updateQuery({ sort: next, page: 1 })
|
||||
}, [sortValue, updateQuery])
|
||||
|
||||
// Update polling state based on documents response
|
||||
const updatePollingState = useCallback((documentsRes: DocumentListResponse | undefined) => {
|
||||
if (!documentsRes?.data)
|
||||
return
|
||||
|
||||
let completedNum = 0
|
||||
documentsRes.data.forEach((documentItem) => {
|
||||
const { indexing_status } = documentItem
|
||||
const isEmbedded = indexing_status === 'completed' || indexing_status === 'paused' || indexing_status === 'error'
|
||||
if (isEmbedded)
|
||||
completedNum++
|
||||
})
|
||||
|
||||
const hasIncompleteDocuments = completedNum !== documentsRes.data.length
|
||||
const transientStatuses = ['queuing', 'indexing', 'paused']
|
||||
const shouldForcePolling = normalizedStatusFilterValue === 'all'
|
||||
? false
|
||||
: transientStatuses.includes(normalizedStatusFilterValue)
|
||||
setTimerCanRun(shouldForcePolling || hasIncompleteDocuments)
|
||||
}, [normalizedStatusFilterValue])
|
||||
|
||||
// Adjust page when total pages change
|
||||
const adjustPageForTotal = useCallback((documentsRes: DocumentListResponse | undefined) => {
|
||||
if (!documentsRes)
|
||||
return
|
||||
const totalPages = Math.ceil(documentsRes.total / limit)
|
||||
if (currPage > 0 && currPage + 1 > totalPages)
|
||||
handlePageChange(totalPages > 0 ? totalPages - 1 : 0)
|
||||
}, [limit, currPage, handlePageChange])
|
||||
|
||||
return {
|
||||
// Search state
|
||||
inputValue,
|
||||
searchValue,
|
||||
debouncedSearchValue,
|
||||
handleInputChange,
|
||||
|
||||
// Filter & sort state
|
||||
statusFilterValue,
|
||||
sortValue,
|
||||
normalizedStatusFilterValue,
|
||||
@@ -177,21 +65,12 @@ export function useDocumentsPageState() {
|
||||
handleStatusFilterClear,
|
||||
handleSortChange,
|
||||
|
||||
// Pagination state
|
||||
currPage,
|
||||
limit,
|
||||
handlePageChange,
|
||||
handleLimitChange,
|
||||
|
||||
// Selection state
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
|
||||
// Polling state
|
||||
timerCanRun,
|
||||
updatePollingState,
|
||||
adjustPageForTotal,
|
||||
}
|
||||
}
|
||||
|
||||
export default useDocumentsPageState
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@@ -13,12 +13,16 @@ import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata
|
||||
import DocumentsHeader from './components/documents-header'
|
||||
import EmptyElement from './components/empty-element'
|
||||
import List from './components/list'
|
||||
import useDocumentsPageState from './hooks/use-documents-page-state'
|
||||
import { useDocumentsPageState } from './hooks/use-documents-page-state'
|
||||
|
||||
type IDocumentsProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
const POLLING_INTERVAL = 2500
|
||||
const TERMINAL_INDEXING_STATUSES = new Set(['completed', 'paused', 'error'])
|
||||
const FORCED_POLLING_STATUSES = new Set(['queuing', 'indexing', 'paused'])
|
||||
|
||||
const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
const router = useRouter()
|
||||
const { plan } = useProviderContext()
|
||||
@@ -44,9 +48,6 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
handleLimitChange,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
timerCanRun,
|
||||
updatePollingState,
|
||||
adjustPageForTotal,
|
||||
} = useDocumentsPageState()
|
||||
|
||||
// Fetch document list
|
||||
@@ -59,19 +60,17 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
status: normalizedStatusFilterValue,
|
||||
sort: sortValue,
|
||||
},
|
||||
refetchInterval: timerCanRun ? 2500 : 0,
|
||||
refetchInterval: (query) => {
|
||||
const shouldForcePolling = normalizedStatusFilterValue !== 'all'
|
||||
&& FORCED_POLLING_STATUSES.has(normalizedStatusFilterValue)
|
||||
const documents = query.state.data?.data
|
||||
if (!documents)
|
||||
return POLLING_INTERVAL
|
||||
const hasIncompleteDocuments = documents.some(({ indexing_status }) => !TERMINAL_INDEXING_STATUSES.has(indexing_status))
|
||||
return shouldForcePolling || hasIncompleteDocuments ? POLLING_INTERVAL : false
|
||||
},
|
||||
})
|
||||
|
||||
// Update polling state when documents change
|
||||
useEffect(() => {
|
||||
updatePollingState(documentsRes)
|
||||
}, [documentsRes, updatePollingState])
|
||||
|
||||
// Adjust page when total changes
|
||||
useEffect(() => {
|
||||
adjustPageForTotal(documentsRes)
|
||||
}, [documentsRes, adjustPageForTotal])
|
||||
|
||||
// Invalidation hooks
|
||||
const invalidDocumentList = useInvalidDocumentList(datasetId)
|
||||
const invalidDocumentDetail = useInvalidDocumentDetail()
|
||||
@@ -119,7 +118,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
|
||||
// Render content based on loading and data state
|
||||
const renderContent = () => {
|
||||
if (isListLoading)
|
||||
if (isListLoading && !documentsRes)
|
||||
return <Loading type="app" />
|
||||
|
||||
if (total > 0) {
|
||||
@@ -131,8 +130,8 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
onUpdate={handleUpdate}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdChange={setSelectedIds}
|
||||
statusFilterValue={normalizedStatusFilterValue}
|
||||
remoteSortValue={sortValue}
|
||||
onSortChange={handleSortChange}
|
||||
pagination={{
|
||||
total,
|
||||
limit,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppList from '../index'
|
||||
|
||||
@@ -132,10 +132,9 @@ const mockMemberRole = (hasEditPermission: boolean) => {
|
||||
|
||||
const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record<string, string>) => {
|
||||
mockMemberRole(hasEditPermission)
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
</NuqsTestingAdapter>,
|
||||
return renderWithNuqs(
|
||||
<AppList onSuccess={onSuccess} />,
|
||||
{ searchParams },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,17 @@ import { slashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
import { zenCommand } from './zen'
|
||||
|
||||
const i18n = getI18n()
|
||||
|
||||
export const slashAction: ActionItem = {
|
||||
key: '/',
|
||||
shortcut: '/',
|
||||
title: i18n.t('gotoAnything.actions.slashTitle', { ns: 'app' }),
|
||||
description: i18n.t('gotoAnything.actions.slashDesc', { ns: 'app' }),
|
||||
get title() {
|
||||
const i18n = getI18n()
|
||||
return i18n.t('gotoAnything.actions.slashTitle', { ns: 'app' })
|
||||
},
|
||||
get description() {
|
||||
const i18n = getI18n()
|
||||
return i18n.t('gotoAnything.actions.slashDesc', { ns: 'app' })
|
||||
},
|
||||
action: (result) => {
|
||||
if (result.type !== 'command')
|
||||
return
|
||||
@@ -28,6 +32,7 @@ export const slashAction: ActionItem = {
|
||||
executeCommand(command, args)
|
||||
},
|
||||
search: async (query, _searchTerm = '') => {
|
||||
const i18n = getI18n()
|
||||
// Delegate all search logic to the command registry system
|
||||
return slashCommandRegistry.search(query, i18n.language)
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
@@ -70,16 +71,26 @@ describe('Compliance', () => {
|
||||
)
|
||||
}
|
||||
|
||||
// Wrapper for tests that need the menu open
|
||||
const renderCompliance = () => {
|
||||
return renderWithQueryClient(
|
||||
<DropdownMenu open={true} onOpenChange={() => {}}>
|
||||
<DropdownMenuTrigger>open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<Compliance />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
}
|
||||
|
||||
const openMenuAndRender = () => {
|
||||
renderWithQueryClient(<Compliance />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderCompliance()
|
||||
fireEvent.click(screen.getByText('common.userProfile.compliance'))
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render compliance menu trigger', () => {
|
||||
// Act
|
||||
renderWithQueryClient(<Compliance />)
|
||||
renderCompliance()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.compliance')).toBeInTheDocument()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowDownCircleLine, RiArrowRightSLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Fragment, useCallback } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
@@ -11,14 +11,14 @@ import { useProviderContext } from '@/context/provider-context'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Button from '../../base/button'
|
||||
import Gdpr from '../../base/icons/src/public/common/Gdpr'
|
||||
import Iso from '../../base/icons/src/public/common/Iso'
|
||||
import Soc2 from '../../base/icons/src/public/common/Soc2'
|
||||
import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft'
|
||||
import PremiumBadge from '../../base/premium-badge'
|
||||
import Spinner from '../../base/spinner'
|
||||
import Toast from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { MenuItemContent } from './menu-item-content'
|
||||
|
||||
enum DocName {
|
||||
SOC2_Type_I = 'SOC2_Type_I',
|
||||
@@ -27,27 +27,83 @@ enum DocName {
|
||||
GDPR = 'GDPR',
|
||||
}
|
||||
|
||||
type UpgradeOrDownloadProps = {
|
||||
doc_name: DocName
|
||||
type ComplianceDocActionVisualProps = {
|
||||
isCurrentPlanCanDownload: boolean
|
||||
isPending: boolean
|
||||
tooltipText: string
|
||||
downloadText: string
|
||||
upgradeText: string
|
||||
}
|
||||
const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
|
||||
|
||||
function ComplianceDocActionVisual({
|
||||
isCurrentPlanCanDownload,
|
||||
isPending,
|
||||
tooltipText,
|
||||
downloadText,
|
||||
upgradeText,
|
||||
}: ComplianceDocActionVisualProps) {
|
||||
if (isCurrentPlanCanDownload) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'btn btn-small btn-secondary pointer-events-none flex items-center gap-[1px]',
|
||||
isPending && 'btn-disabled',
|
||||
)}
|
||||
>
|
||||
<span className="i-ri-arrow-down-circle-line size-[14px] text-components-button-secondary-text-disabled" />
|
||||
<span className="px-[3px] text-components-button-secondary-text system-xs-medium">{downloadText}</span>
|
||||
{isPending && <Spinner loading={true} className="!ml-1 !h-3 !w-3 !border-2 !text-text-tertiary" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const canShowUpgradeTooltip = tooltipText.length > 0
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
delay={0}
|
||||
disabled={!canShowUpgradeTooltip}
|
||||
render={(
|
||||
<PremiumBadge color="blue" allowHover={true}>
|
||||
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
|
||||
<div className="px-1 system-xs-medium">
|
||||
{upgradeText}
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
/>
|
||||
{canShowUpgradeTooltip && (
|
||||
<TooltipContent>
|
||||
{tooltipText}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type ComplianceDocRowItemProps = {
|
||||
icon: ReactNode
|
||||
label: ReactNode
|
||||
docName: DocName
|
||||
}
|
||||
|
||||
function ComplianceDocRowItem({
|
||||
icon,
|
||||
label,
|
||||
docName,
|
||||
}: ComplianceDocRowItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
const isFreePlan = plan.type === Plan.sandbox
|
||||
|
||||
const handlePlanClick = useCallback(() => {
|
||||
if (isFreePlan)
|
||||
setShowPricingModal()
|
||||
else
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
|
||||
|
||||
const { isPending, mutate: downloadCompliance } = useMutation({
|
||||
mutationKey: ['downloadCompliance', doc_name],
|
||||
mutationKey: ['downloadCompliance', docName],
|
||||
mutationFn: async () => {
|
||||
try {
|
||||
const ret = await getDocDownloadUrl(doc_name)
|
||||
const ret = await getDocDownloadUrl(docName)
|
||||
downloadUrl({ url: ret.url })
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
@@ -63,6 +119,7 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const whichPlanCanDownloadCompliance = {
|
||||
[DocName.SOC2_Type_I]: [Plan.professional, Plan.team],
|
||||
[DocName.SOC2_Type_II]: [Plan.team],
|
||||
@@ -70,118 +127,85 @@ const UpgradeOrDownload: FC<UpgradeOrDownloadProps> = ({ doc_name }) => {
|
||||
[DocName.GDPR]: [Plan.team, Plan.professional, Plan.sandbox],
|
||||
}
|
||||
|
||||
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[doc_name].includes(plan.type)
|
||||
const handleDownloadClick = useCallback((e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
downloadCompliance()
|
||||
}, [downloadCompliance])
|
||||
if (isCurrentPlanCanDownload) {
|
||||
return (
|
||||
<Button loading={isPending} disabled={isPending} size="small" variant="secondary" className="flex items-center gap-[1px]" onClick={handleDownloadClick}>
|
||||
<RiArrowDownCircleLine className="size-[14px] text-components-button-secondary-text-disabled" />
|
||||
<span className="system-xs-medium px-[3px] text-components-button-secondary-text">{t('operation.download', { ns: 'common' })}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type)
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (isCurrentPlanCanDownload) {
|
||||
if (!isPending)
|
||||
downloadCompliance()
|
||||
return
|
||||
}
|
||||
|
||||
if (isFreePlan)
|
||||
setShowPricingModal()
|
||||
else
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
}, [downloadCompliance, isCurrentPlanCanDownload, isFreePlan, isPending, setShowAccountSettingModal, setShowPricingModal])
|
||||
|
||||
const upgradeTooltip: Record<Plan, string> = {
|
||||
[Plan.sandbox]: t('compliance.sandboxUpgradeTooltip', { ns: 'common' }),
|
||||
[Plan.professional]: t('compliance.professionalUpgradeTooltip', { ns: 'common' }),
|
||||
[Plan.team]: '',
|
||||
[Plan.enterprise]: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip asChild={false} popupContent={upgradeTooltip[plan.type]}>
|
||||
<PremiumBadge color="blue" allowHover={true} onClick={handlePlanClick}>
|
||||
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
|
||||
<div className="system-xs-medium">
|
||||
<span className="p-1">
|
||||
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</Tooltip>
|
||||
<DropdownMenuItem
|
||||
className="h-10 justify-between py-1 pl-1 pr-2"
|
||||
closeOnClick={!isCurrentPlanCanDownload}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{icon}
|
||||
<div className="grow truncate px-1 text-text-secondary system-md-regular">{label}</div>
|
||||
<ComplianceDocActionVisual
|
||||
isCurrentPlanCanDownload={isCurrentPlanCanDownload}
|
||||
isPending={isPending}
|
||||
tooltipText={upgradeTooltip[plan.type]}
|
||||
downloadText={t('operation.download', { ns: 'common' })}
|
||||
upgradeText={t('upgradeBtn.encourageShort', { ns: 'billing' })}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
|
||||
export default function Compliance() {
|
||||
const itemClassName = `
|
||||
flex items-center w-full h-10 pl-1 pr-2 py-1 text-text-secondary system-md-regular
|
||||
rounded-lg hover:bg-state-base-hover gap-1
|
||||
`
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={
|
||||
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')
|
||||
}
|
||||
>
|
||||
<RiVerifiedBadgeLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.compliance', { ns: 'common' })}</div>
|
||||
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
`absolute top-[1px] z-10 max-h-[70vh] w-[337px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-scroll
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Soc2 className="size-7 shrink-0" />
|
||||
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type1', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.SOC2_Type_I} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Soc2 className="size-7 shrink-0" />
|
||||
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.soc2Type2', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.SOC2_Type_II} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Iso className="size-7 shrink-0" />
|
||||
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.iso27001', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.ISO_27001} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<Gdpr className="size-7 shrink-0" />
|
||||
<div className="system-md-regular grow truncate px-1 text-text-secondary">{t('compliance.gdpr', { ns: 'common' })}</div>
|
||||
<UpgradeOrDownload doc_name={DocName.GDPR} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-verified-badge-line"
|
||||
label={t('userProfile.compliance', { ns: 'common' })}
|
||||
/>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
<ComplianceDocRowItem
|
||||
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.soc2Type1', { ns: 'common' })}
|
||||
docName={DocName.SOC2_Type_I}
|
||||
/>
|
||||
<ComplianceDocRowItem
|
||||
icon={<Soc2 aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.soc2Type2', { ns: 'common' })}
|
||||
docName={DocName.SOC2_Type_II}
|
||||
/>
|
||||
<ComplianceDocRowItem
|
||||
icon={<Iso aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.iso27001', { ns: 'common' })}
|
||||
docName={DocName.ISO_27001}
|
||||
/>
|
||||
<ComplianceDocRowItem
|
||||
icon={<Gdpr aria-hidden className="size-7 shrink-0" />}
|
||||
label={t('compliance.gdpr', { ns: 'common' })}
|
||||
docName={DocName.GDPR}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ vi.mock('@/context/i18n', () => ({
|
||||
const { mockConfig, mockEnv } = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
IS_CLOUD_EDITION: false,
|
||||
ZENDESK_WIDGET_KEY: '',
|
||||
},
|
||||
mockEnv: {
|
||||
env: {
|
||||
@@ -74,6 +75,7 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
|
||||
}))
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
|
||||
get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
|
||||
IS_DEV: false,
|
||||
IS_CE_EDITION: false,
|
||||
}))
|
||||
@@ -187,6 +189,14 @@ describe('AccountDropdown', () => {
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set an accessible label on avatar trigger when menu trigger is rendered', () => {
|
||||
// Act
|
||||
renderWithRouter(<AppSelector />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show EDU badge for education accounts', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import {
|
||||
RiAccountCircleLine,
|
||||
RiArrowRightUpLine,
|
||||
RiBookOpenLine,
|
||||
RiGithubLine,
|
||||
RiGraduationCapFill,
|
||||
RiInformation2Line,
|
||||
RiLogoutBoxRLine,
|
||||
RiMap2Line,
|
||||
RiSettings3Line,
|
||||
RiStarLine,
|
||||
RiTShirt2Line,
|
||||
} from '@remixicon/react'
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Fragment, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
import Avatar from '@/app/components/base/avatar'
|
||||
import PremiumBadge from '@/app/components/base/premium-badge'
|
||||
import ThemeSwitcher from '@/app/components/base/theme-switcher'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@@ -35,15 +24,90 @@ import AccountAbout from '../account-about'
|
||||
import GithubStar from '../github-star'
|
||||
import Indicator from '../indicator'
|
||||
import Compliance from './compliance'
|
||||
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
|
||||
import Support from './support'
|
||||
|
||||
type AccountMenuRouteItemProps = {
|
||||
href: string
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuRouteItem({
|
||||
href,
|
||||
iconClassName,
|
||||
label,
|
||||
trailing,
|
||||
}: AccountMenuRouteItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
render={<Link href={href} />}
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountMenuExternalItemProps = {
|
||||
href: string
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuExternalItem({
|
||||
href,
|
||||
iconClassName,
|
||||
label,
|
||||
trailing,
|
||||
}: AccountMenuExternalItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountMenuActionItemProps = {
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
onClick?: MouseEventHandler<HTMLElement>
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuActionItem({
|
||||
iconClassName,
|
||||
label,
|
||||
onClick,
|
||||
trailing,
|
||||
}: AccountMenuActionItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
onClick={onClick}
|
||||
>
|
||||
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
type AccountMenuSectionProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function AccountMenuSection({ children }: AccountMenuSectionProps) {
|
||||
return <DropdownMenuGroup className="p-1">{children}</DropdownMenuGroup>
|
||||
}
|
||||
|
||||
export default function AppSelector() {
|
||||
const itemClassName = `
|
||||
flex items-center w-full h-8 pl-3 pr-2 text-text-secondary system-md-regular
|
||||
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
|
||||
`
|
||||
const router = useRouter()
|
||||
const [aboutVisible, setAboutVisible] = useState(false)
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
|
||||
const { t } = useTranslation()
|
||||
@@ -68,161 +132,124 @@ export default function AppSelector() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
{
|
||||
({ open, close }) => (
|
||||
<>
|
||||
<MenuButton className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', open && 'bg-background-default-dodge')}>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className="
|
||||
absolute right-0 mt-1.5 w-60 max-w-80
|
||||
origin-top-right divide-y divide-divider-subtle rounded-xl bg-components-panel-bg-blur shadow-lg
|
||||
backdrop-blur-sm focus:outline-none
|
||||
"
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<MenuItem disabled>
|
||||
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<div className="grow">
|
||||
<div className="system-md-medium break-all text-text-primary">
|
||||
{userProfile.name}
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
|
||||
<RiGraduationCapFill className="mr-1 h-3 w-3" />
|
||||
<span className="system-2xs-medium">EDU</span>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className="system-xs-regular break-all text-text-tertiary">{userProfile.email}</div>
|
||||
</div>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group', 'data-[active]:bg-state-base-hover')}
|
||||
href="/account"
|
||||
target="_self"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiAccountCircleLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('account.account', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'data-[active]:bg-state-base-hover')}
|
||||
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
|
||||
>
|
||||
<RiSettings3Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.settings', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</div>
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href={docLink('/use-dify/getting-started/introduction')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiBookOpenLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.helpCenter', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<Support closeAccountDropdown={close} />
|
||||
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://roadmap.dify.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiMap2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.roadmap', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://github.com/langgenius/dify"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiGithubLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.github', { ns: 'common' })}</div>
|
||||
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
|
||||
<RiStarLine className="size-3 shrink-0 text-text-tertiary" />
|
||||
<GithubStar className="system-2xs-medium-uppercase text-text-tertiary" />
|
||||
</div>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
{
|
||||
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
|
||||
<MenuItem>
|
||||
<div
|
||||
className={cn(itemClassName, 'justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
onClick={() => setAboutVisible(true)}
|
||||
>
|
||||
<RiInformation2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.about', { ns: 'common' })}</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="system-xs-regular mr-2 text-text-tertiary">{langGeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('account.account', { ns: 'common' })}
|
||||
className={cn('inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge', isAccountMenuOpen && 'bg-background-default-dodge')}
|
||||
>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
sideOffset={6}
|
||||
popupClassName="w-60 max-w-80 !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="px-1 py-1">
|
||||
<div className="flex flex-nowrap items-center py-2 pl-3 pr-2">
|
||||
<div className="grow">
|
||||
<div className="break-all text-text-primary system-md-medium">
|
||||
{userProfile.name}
|
||||
{isEducationAccount && (
|
||||
<PremiumBadge size="s" color="blue" className="ml-1 !px-2">
|
||||
<span aria-hidden className="i-ri-graduation-cap-fill mr-1 h-3 w-3" />
|
||||
<span className="system-2xs-medium">EDU</span>
|
||||
</PremiumBadge>
|
||||
)}
|
||||
<MenuItem disabled>
|
||||
<div className="p-1">
|
||||
<div className={cn(itemClassName, 'hover:bg-transparent')}>
|
||||
<RiTShirt2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('theme.theme', { ns: 'common' })}</div>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
<div className="break-all text-text-tertiary system-xs-regular">{userProfile.email}</div>
|
||||
</div>
|
||||
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
|
||||
</div>
|
||||
<AccountMenuRouteItem
|
||||
href="/account"
|
||||
iconClassName="i-ri-account-circle-line"
|
||||
label={t('account.account', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
<AccountMenuActionItem
|
||||
iconClassName="i-ri-settings-3-line"
|
||||
label={t('userProfile.settings', { ns: 'common' })}
|
||||
onClick={() => setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.MEMBERS })}
|
||||
/>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<>
|
||||
<AccountMenuSection>
|
||||
<AccountMenuExternalItem
|
||||
href={docLink('/use-dify/getting-started/introduction')}
|
||||
iconClassName="i-ri-book-open-line"
|
||||
label={t('userProfile.helpCenter', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
<Support closeAccountDropdown={() => setIsAccountMenuOpen(false)} />
|
||||
{IS_CLOUD_EDITION && isCurrentWorkspaceOwner && <Compliance />}
|
||||
</AccountMenuSection>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
<AccountMenuSection>
|
||||
<AccountMenuExternalItem
|
||||
href="https://roadmap.dify.ai"
|
||||
iconClassName="i-ri-map-2-line"
|
||||
label={t('userProfile.roadmap', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
<AccountMenuExternalItem
|
||||
href="https://github.com/langgenius/dify"
|
||||
iconClassName="i-ri-github-line"
|
||||
label={t('userProfile.github', { ns: 'common' })}
|
||||
trailing={(
|
||||
<div className="flex items-center gap-0.5 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] py-[3px]">
|
||||
<span aria-hidden className="i-ri-star-line size-3 shrink-0 text-text-tertiary" />
|
||||
<GithubStar className="text-text-tertiary system-2xs-medium-uppercase" />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<div className="p-1" onClick={() => handleLogout()}>
|
||||
<div
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
>
|
||||
<RiLogoutBoxRLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.logout', { ns: 'common' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
)}
|
||||
/>
|
||||
{
|
||||
env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
|
||||
<AccountMenuActionItem
|
||||
iconClassName="i-ri-information-2-line"
|
||||
label={t('userProfile.about', { ns: 'common' })}
|
||||
onClick={() => {
|
||||
setAboutVisible(true)
|
||||
setIsAccountMenuOpen(false)
|
||||
}}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="mr-2 text-text-tertiary system-xs-regular">{langGeniusVersionInfo.current_version}</div>
|
||||
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</AccountMenuSection>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
)}
|
||||
<AccountMenuSection>
|
||||
<DropdownMenuItem
|
||||
className="cursor-default data-[highlighted]:bg-transparent"
|
||||
onSelect={e => e.preventDefault()}
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-t-shirt-2-line"
|
||||
label={t('theme.theme', { ns: 'common' })}
|
||||
trailing={<ThemeSwitcher />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</AccountMenuSection>
|
||||
<DropdownMenuSeparator className="!my-0 bg-divider-subtle" />
|
||||
<AccountMenuSection>
|
||||
<AccountMenuActionItem
|
||||
iconClassName="i-ri-logout-box-r-line"
|
||||
label={t('userProfile.logout', { ns: 'common' })}
|
||||
onClick={() => {
|
||||
void handleLogout()
|
||||
}}
|
||||
/>
|
||||
</AccountMenuSection>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{
|
||||
aboutVisible && <AccountAbout onCancel={() => setAboutVisible(false)} langGeniusVersionInfo={langGeniusVersionInfo} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const menuLabelClassName = 'min-w-0 grow truncate px-1 text-text-secondary system-md-regular'
|
||||
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
|
||||
|
||||
export const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
|
||||
|
||||
type MenuItemContentProps = {
|
||||
iconClassName: string
|
||||
label: ReactNode
|
||||
trailing?: ReactNode
|
||||
}
|
||||
|
||||
export function MenuItemContent({
|
||||
iconClassName,
|
||||
label,
|
||||
trailing,
|
||||
}: MenuItemContentProps) {
|
||||
return (
|
||||
<>
|
||||
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
|
||||
<div className={menuLabelClassName}>{label}</div>
|
||||
{trailing}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExternalLinkIndicator() {
|
||||
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
@@ -93,10 +94,21 @@ describe('Support', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const renderSupport = () => {
|
||||
return render(
|
||||
<DropdownMenu open={true} onOpenChange={() => {}}>
|
||||
<DropdownMenuTrigger>open</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<Support closeAccountDropdown={mockCloseAccountDropdown} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render support menu trigger', () => {
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
renderSupport()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.support')).toBeInTheDocument()
|
||||
@@ -104,8 +116,8 @@ describe('Support', () => {
|
||||
|
||||
it('should show forum and community links when opened', () => {
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
|
||||
@@ -116,8 +128,8 @@ describe('Support', () => {
|
||||
describe('Plan-based Channels', () => {
|
||||
it('should show "Contact Us" when ZENDESK_WIDGET_KEY is present', () => {
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
|
||||
@@ -134,8 +146,8 @@ describe('Support', () => {
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.userProfile.contactUs')).not.toBeInTheDocument()
|
||||
@@ -147,8 +159,8 @@ describe('Support', () => {
|
||||
mockZendeskKey.value = ''
|
||||
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.emailSupport')).toBeInTheDocument()
|
||||
@@ -159,8 +171,8 @@ describe('Support', () => {
|
||||
describe('Interactions and Links', () => {
|
||||
it('should call toggleZendeskWindow and closeAccountDropdown when "Contact Us" is clicked', () => {
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
fireEvent.click(screen.getByText('common.userProfile.contactUs'))
|
||||
|
||||
// Assert
|
||||
@@ -170,8 +182,8 @@ describe('Support', () => {
|
||||
|
||||
it('should have correct forum and community links', () => {
|
||||
// Act
|
||||
render(<Support closeAccountDropdown={mockCloseAccountDropdown} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
renderSupport()
|
||||
fireEvent.click(screen.getByText('common.userProfile.support'))
|
||||
|
||||
// Assert
|
||||
const forumLink = screen.getByText('common.userProfile.forum').closest('a')
|
||||
|
||||
@@ -1,119 +1,85 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { RiArrowRightSLine, RiArrowRightUpLine, RiChatSmile2Line, RiDiscordLine, RiDiscussLine, RiMailSendLine, RiQuestionLine } from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import { Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ZENDESK_WIDGET_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { mailToSupport } from '../utils/util'
|
||||
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
|
||||
|
||||
type SupportProps = {
|
||||
closeAccountDropdown: () => void
|
||||
}
|
||||
|
||||
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
|
||||
export default function Support({ closeAccountDropdown }: SupportProps) {
|
||||
const itemClassName = `
|
||||
flex items-center w-full h-9 pl-3 pr-2 text-text-secondary system-md-regular
|
||||
rounded-lg hover:bg-state-base-hover cursor-pointer gap-1
|
||||
`
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { userProfile, langGeniusVersionInfo } = useAppContext()
|
||||
const hasDedicatedChannel = plan.type !== Plan.sandbox
|
||||
const hasZendeskWidget = !!ZENDESK_WIDGET_KEY?.trim()
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={
|
||||
cn('group flex h-9 w-full items-center gap-1 rounded-lg py-2 pl-3 pr-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')
|
||||
}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-question-line"
|
||||
label={t('userProfile.support', { ns: 'common' })}
|
||||
/>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
|
||||
>
|
||||
<DropdownMenuGroup className="p-1">
|
||||
{hasDedicatedChannel && hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
onClick={() => {
|
||||
toggleZendeskWindow(true)
|
||||
closeAccountDropdown()
|
||||
}}
|
||||
>
|
||||
<RiQuestionLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-left text-text-secondary">{t('userProfile.support', { ns: 'common' })}</div>
|
||||
<RiArrowRightSLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-chat-smile-2-line"
|
||||
label={t('userProfile.contactUs', { ns: 'common' })}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasDedicatedChannel && !hasZendeskWidget && (
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
`absolute top-[1px] z-10 max-h-[70vh] w-[216px] origin-top-right -translate-x-full divide-y divide-divider-subtle overflow-y-auto
|
||||
rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px] focus:outline-none
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
{hasDedicatedChannel && (
|
||||
<MenuItem>
|
||||
{ZENDESK_WIDGET_KEY && ZENDESK_WIDGET_KEY.trim() !== ''
|
||||
? (
|
||||
<button
|
||||
className={cn(itemClassName, 'group justify-between text-left data-[active]:bg-state-base-hover')}
|
||||
onClick={() => {
|
||||
toggleZendeskWindow(true)
|
||||
closeAccountDropdown()
|
||||
}}
|
||||
>
|
||||
<RiChatSmile2Line className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.contactUs', { ns: 'common' })}</div>
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<a
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiMailSendLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.emailSupport', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</a>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://forum.dify.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiDiscussLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.forum', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link
|
||||
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
|
||||
href="https://discord.gg/5AEfbxcd9k"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<RiDiscordLine className="size-4 shrink-0 text-text-tertiary" />
|
||||
<div className="system-md-regular grow px-1 text-text-secondary">{t('userProfile.community', { ns: 'common' })}</div>
|
||||
<RiArrowRightUpLine className="size-[14px] shrink-0 text-text-tertiary" />
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-mail-send-line"
|
||||
label={t('userProfile.emailSupport', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discuss-line"
|
||||
label={t('userProfile.forum', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
|
||||
>
|
||||
<MenuItemContent
|
||||
iconClassName="i-ri-discord-line"
|
||||
label={t('userProfile.community', { ns: 'common' })}
|
||||
trailing={<ExternalLinkIndicator />}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import { DEFAULT_SORT } from '../constants'
|
||||
|
||||
const createWrapper = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
<NuqsWrapper>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</NuqsWrapper>
|
||||
</JotaiProvider>
|
||||
)
|
||||
return { wrapper, onUrlUpdate }
|
||||
return { wrapper }
|
||||
}
|
||||
|
||||
describe('Marketplace sort atoms', () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import PluginTypeSwitch from '../plugin-type-switch'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
@@ -25,15 +24,15 @@ vi.mock('#i18n', () => ({
|
||||
}))
|
||||
|
||||
const createWrapper = (searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
<NuqsWrapper>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</NuqsWrapper>
|
||||
</JotaiProvider>
|
||||
)
|
||||
return { Wrapper, onUrlUpdate }
|
||||
return { Wrapper }
|
||||
}
|
||||
|
||||
describe('PluginTypeSwitch', () => {
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: '/api',
|
||||
@@ -37,6 +37,7 @@ vi.mock('@/service/client', () => ({
|
||||
}))
|
||||
|
||||
const createWrapper = (searchParams = '') => {
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams })
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
@@ -45,9 +46,9 @@ const createWrapper = (searchParams = '') => {
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<NuqsWrapper>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</NuqsWrapper>
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { Provider as JotaiProvider } from 'jotai'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
import StickySearchAndSwitchWrapper from '../sticky-search-and-switch-wrapper'
|
||||
|
||||
vi.mock('#i18n', () => ({
|
||||
@@ -20,13 +20,17 @@ vi.mock('../search-box/search-box-wrapper', () => ({
|
||||
default: () => <div data-testid="search-box-wrapper">SearchBoxWrapper</div>,
|
||||
}))
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
<NuqsTestingAdapter>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
</JotaiProvider>
|
||||
)
|
||||
const createWrapper = () => {
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<JotaiProvider>
|
||||
<NuqsWrapper>
|
||||
{children}
|
||||
</NuqsWrapper>
|
||||
</JotaiProvider>
|
||||
)
|
||||
return { Wrapper }
|
||||
}
|
||||
|
||||
describe('StickySearchAndSwitchWrapper', () => {
|
||||
beforeEach(() => {
|
||||
@@ -34,6 +38,7 @@ describe('StickySearchAndSwitchWrapper', () => {
|
||||
})
|
||||
|
||||
it('should render SearchBoxWrapper and PluginTypeSwitch', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { getByTestId } = render(
|
||||
<StickySearchAndSwitchWrapper />,
|
||||
{ wrapper: Wrapper },
|
||||
@@ -44,6 +49,7 @@ describe('StickySearchAndSwitchWrapper', () => {
|
||||
})
|
||||
|
||||
it('should not apply sticky class when no pluginTypeSwitchClassName', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(
|
||||
<StickySearchAndSwitchWrapper />,
|
||||
{ wrapper: Wrapper },
|
||||
@@ -55,6 +61,7 @@ describe('StickySearchAndSwitchWrapper', () => {
|
||||
})
|
||||
|
||||
it('should apply sticky class when pluginTypeSwitchClassName contains top-', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(
|
||||
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="top-10" />,
|
||||
{ wrapper: Wrapper },
|
||||
@@ -67,6 +74,7 @@ describe('StickySearchAndSwitchWrapper', () => {
|
||||
})
|
||||
|
||||
it('should not apply sticky class when pluginTypeSwitchClassName does not contain top-', () => {
|
||||
const { Wrapper } = createWrapper()
|
||||
const { container } = render(
|
||||
<StickySearchAndSwitchWrapper pluginTypeSwitchClassName="custom-class" />,
|
||||
{ wrapper: Wrapper },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import type { MarketplaceSearchParams } from './search-params'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { createLoader } from 'nuqs/server'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
@@ -14,7 +15,7 @@ async function getDehydratedState(searchParams?: Promise<SearchParams>) {
|
||||
return
|
||||
}
|
||||
const loadSearchParams = createLoader(marketplaceSearchParamsParsers)
|
||||
const params = await loadSearchParams(searchParams)
|
||||
const params: MarketplaceSearchParams = await loadSearchParams(searchParams)
|
||||
|
||||
if (!PLUGIN_CATEGORY_WITH_COLLECTIONS.has(params.category)) {
|
||||
return
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { inferParserType } from 'nuqs/server'
|
||||
import type { ActivePluginType } from './constants'
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringEnum } from 'nuqs/server'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from './constants'
|
||||
@@ -7,3 +8,5 @@ export const marketplaceSearchParamsParsers = {
|
||||
q: parseAsString.withDefault('').withOptions({ history: 'replace' }),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]).withOptions({ history: 'replace' }),
|
||||
}
|
||||
|
||||
export type MarketplaceSearchParams = inferParserType<typeof marketplaceSearchParamsParsers>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { PluginPageContext, PluginPageContextProvider, usePluginPageContext } fr
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('nuqs', () => ({
|
||||
parseAsStringEnum: vi.fn(() => ({
|
||||
withDefault: vi.fn(() => ({})),
|
||||
})),
|
||||
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
|
||||
}))
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ vi.mock('@/service/use-plugins', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('nuqs', () => ({
|
||||
parseAsStringEnum: vi.fn(() => ({
|
||||
withDefault: vi.fn(() => ({})),
|
||||
})),
|
||||
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
|
||||
}))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type { FilterState } from './filter-management'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -15,6 +15,19 @@ import {
|
||||
} from 'use-context-selector'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
|
||||
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
|
||||
|
||||
export type PluginPageTab = typeof PLUGIN_PAGE_TABS_MAP[keyof typeof PLUGIN_PAGE_TABS_MAP]
|
||||
| (typeof PLUGIN_TYPE_SEARCH_MAP)[keyof typeof PLUGIN_TYPE_SEARCH_MAP]
|
||||
|
||||
const PLUGIN_PAGE_TAB_VALUES: PluginPageTab[] = [
|
||||
PLUGIN_PAGE_TABS_MAP.plugins,
|
||||
PLUGIN_PAGE_TABS_MAP.marketplace,
|
||||
...Object.values(PLUGIN_TYPE_SEARCH_MAP),
|
||||
]
|
||||
|
||||
const parseAsPluginPageTab = parseAsStringEnum<PluginPageTab>(PLUGIN_PAGE_TAB_VALUES)
|
||||
.withDefault(PLUGIN_PAGE_TABS_MAP.plugins)
|
||||
|
||||
export type PluginPageContextValue = {
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
@@ -22,8 +35,8 @@ export type PluginPageContextValue = {
|
||||
setCurrentPluginID: (pluginID?: string) => void
|
||||
filters: FilterState
|
||||
setFilters: (filter: FilterState) => void
|
||||
activeTab: string
|
||||
setActiveTab: (tab: string) => void
|
||||
activeTab: PluginPageTab
|
||||
setActiveTab: (tab: PluginPageTab) => void
|
||||
options: Array<{ value: string, text: string }>
|
||||
}
|
||||
|
||||
@@ -39,7 +52,7 @@ export const PluginPageContext = createContext<PluginPageContextValue>({
|
||||
searchQuery: '',
|
||||
},
|
||||
setFilters: noop,
|
||||
activeTab: '',
|
||||
activeTab: PLUGIN_PAGE_TABS_MAP.plugins,
|
||||
setActiveTab: noop,
|
||||
options: [],
|
||||
})
|
||||
@@ -68,9 +81,7 @@ export const PluginPageContextProvider = ({
|
||||
const options = useMemo(() => {
|
||||
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
|
||||
}, [tabs, enable_marketplace])
|
||||
const [activeTab, setActiveTab] = useQueryState('tab', {
|
||||
defaultValue: options[0].value,
|
||||
})
|
||||
const [activeTab, setActiveTab] = useQueryState('tab', parseAsPluginPageTab)
|
||||
|
||||
return (
|
||||
<PluginPageContext.Provider
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { Dependency, PluginDeclaration, PluginManifestInMarket } from '../types'
|
||||
import type { PluginPageTab } from './context'
|
||||
import {
|
||||
RiBookOpenLine,
|
||||
RiDragDropLine,
|
||||
@@ -37,6 +38,16 @@ import PluginTasks from './plugin-tasks'
|
||||
import useReferenceSetting from './use-reference-setting'
|
||||
import { useUploader } from './use-uploader'
|
||||
|
||||
const pluginPageTabSet = new Set<string>([
|
||||
PLUGIN_PAGE_TABS_MAP.plugins,
|
||||
PLUGIN_PAGE_TABS_MAP.marketplace,
|
||||
...Object.values(PLUGIN_TYPE_SEARCH_MAP),
|
||||
])
|
||||
|
||||
const isPluginPageTab = (value: string): value is PluginPageTab => {
|
||||
return pluginPageTabSet.has(value)
|
||||
}
|
||||
|
||||
export type PluginPageProps = {
|
||||
plugins: React.ReactNode
|
||||
marketplace: React.ReactNode
|
||||
@@ -154,7 +165,10 @@ const PluginPage = ({
|
||||
<div className="flex-1">
|
||||
<TabSlider
|
||||
value={isPluginsTab ? PLUGIN_PAGE_TABS_MAP.plugins : PLUGIN_PAGE_TABS_MAP.marketplace}
|
||||
onChange={setActiveTab}
|
||||
onChange={(nextTab) => {
|
||||
if (isPluginPageTab(nextTab))
|
||||
setActiveTab(nextTab)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { ToolTypeEnum } from '../../workflow/block-selector/types'
|
||||
import ProviderList from '../provider-list'
|
||||
import { getToolType } from '../utils'
|
||||
@@ -206,10 +206,9 @@ describe('getToolType', () => {
|
||||
})
|
||||
|
||||
const renderProviderList = (searchParams?: Record<string, string>) => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<ProviderList />
|
||||
</NuqsTestingAdapter>,
|
||||
return renderWithNuqs(
|
||||
<ProviderList />,
|
||||
{ searchParams },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { Collection } from './types'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
@@ -23,6 +23,17 @@ import { useMarketplace } from './marketplace/hooks'
|
||||
import MCPList from './mcp'
|
||||
import { getToolType } from './utils'
|
||||
|
||||
const TOOL_PROVIDER_CATEGORY_VALUES = ['builtin', 'api', 'workflow', 'mcp'] as const
|
||||
type ToolProviderCategory = typeof TOOL_PROVIDER_CATEGORY_VALUES[number]
|
||||
const toolProviderCategorySet = new Set<string>(TOOL_PROVIDER_CATEGORY_VALUES)
|
||||
|
||||
const isToolProviderCategory = (value: string): value is ToolProviderCategory => {
|
||||
return toolProviderCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsToolProviderCategory = parseAsStringLiteral(TOOL_PROVIDER_CATEGORY_VALUES)
|
||||
.withDefault('builtin')
|
||||
|
||||
const ProviderList = () => {
|
||||
// const searchParams = useSearchParams()
|
||||
// searchParams.get('category') === 'workflow'
|
||||
@@ -31,9 +42,7 @@ const ProviderList = () => {
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [activeTab, setActiveTab] = useQueryState('category', {
|
||||
defaultValue: 'builtin',
|
||||
})
|
||||
const [activeTab, setActiveTab] = useQueryState('category', parseAsToolProviderCategory)
|
||||
const options = [
|
||||
{ value: 'builtin', text: t('type.builtIn', { ns: 'tools' }) },
|
||||
{ value: 'api', text: t('type.custom', { ns: 'tools' }) },
|
||||
@@ -124,6 +133,8 @@ const ProviderList = () => {
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={(state) => {
|
||||
if (!isToolProviderCategory(state))
|
||||
return
|
||||
setActiveTab(state)
|
||||
if (state !== activeTab)
|
||||
setCurrentProviderId(undefined)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getDatasetMap } from '@/env'
|
||||
import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { ToastProvider } from './components/base/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import BrowserInitializer from './components/browser-initializer'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
@@ -79,7 +80,9 @@ const LocaleLayout = async ({
|
||||
<I18nServerProvider>
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
{children}
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
</GlobalPublicStoreProvider>
|
||||
</ToastProvider>
|
||||
</I18nServerProvider>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ModalContextProvider } from '@/context/modal-context'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
@@ -71,12 +71,10 @@ const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({
|
||||
},
|
||||
})
|
||||
|
||||
const renderProvider = () => render(
|
||||
<NuqsTestingAdapter>
|
||||
<ModalContextProvider>
|
||||
<div data-testid="modal-context-test-child" />
|
||||
</ModalContextProvider>
|
||||
</NuqsTestingAdapter>,
|
||||
const renderProvider = () => renderWithNuqs(
|
||||
<ModalContextProvider>
|
||||
<div data-testid="modal-context-test-child" />
|
||||
</ModalContextProvider>,
|
||||
)
|
||||
|
||||
describe('ModalContextProvider trigger events limit modal', () => {
|
||||
|
||||
@@ -158,7 +158,7 @@ export const ModalContextProvider = ({
|
||||
}: ModalContextProviderProps) => {
|
||||
// Use nuqs hooks for URL-based modal state management
|
||||
const [showPricingModal, setPricingModalOpen] = usePricingModal()
|
||||
const [urlAccountModalState, setUrlAccountModalState] = useAccountSettingModal<AccountSettingTab>()
|
||||
const [urlAccountModalState, setUrlAccountModalState] = useAccountSettingModal()
|
||||
|
||||
const accountSettingCallbacksRef = useRef<Omit<ModalState<AccountSettingTab>, 'payload'> | null>(null)
|
||||
const accountSettingTab = urlAccountModalState.isOpen
|
||||
|
||||
@@ -43,6 +43,8 @@ This command lints the entire project and is intended for final verification bef
|
||||
If a new rule causes many existing code errors or automatic fixes generate too many diffs, do not use the `--fix` option for automatic fixes.
|
||||
You can introduce the rule first, then use the `--suppress-all` option to temporarily suppress these errors, and gradually fix them in subsequent changes.
|
||||
|
||||
For overlay migration policy and cleanup phases, see [Overlay Migration Guide](./overlay-migration.md).
|
||||
|
||||
## Type Check
|
||||
|
||||
You should be able to see suggestions from TypeScript in your editor for all open files.
|
||||
|
||||
50
web/docs/overlay-migration.md
Normal file
50
web/docs/overlay-migration.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Overlay Migration Guide
|
||||
|
||||
This document tracks the migration away from legacy `portal-to-follow-elem` APIs.
|
||||
|
||||
## Scope
|
||||
|
||||
- Deprecated API: `@/app/components/base/portal-to-follow-elem`
|
||||
- Replacement primitives:
|
||||
- `@/app/components/base/ui/tooltip`
|
||||
- `@/app/components/base/ui/dropdown-menu`
|
||||
- `@/app/components/base/ui/popover`
|
||||
- `@/app/components/base/ui/dialog`
|
||||
- `@/app/components/base/ui/select`
|
||||
- Tracking issue: https://github.com/langgenius/dify/issues/32767
|
||||
|
||||
## ESLint policy
|
||||
|
||||
- `no-restricted-imports` blocks new usage of `portal-to-follow-elem`.
|
||||
- The rule is enabled for normal source files and test files are excluded.
|
||||
- Legacy `app/components/base/*` callers are temporarily allowlisted in ESLint config.
|
||||
- New files must not be added to the allowlist without migration owner approval.
|
||||
|
||||
## Migration phases
|
||||
|
||||
1. Business/UI features outside `app/components/base/**`
|
||||
- Migrate old calls to semantic primitives.
|
||||
- Keep `eslint-suppressions.json` stable or shrinking.
|
||||
1. Legacy base components in allowlist
|
||||
- Migrate allowlisted base callers gradually.
|
||||
- Remove migrated files from allowlist immediately.
|
||||
1. Cleanup
|
||||
- Remove remaining suppressions for `no-restricted-imports`.
|
||||
- Remove legacy `portal-to-follow-elem` implementation.
|
||||
|
||||
## Suppression maintenance
|
||||
|
||||
- After each migration batch, run:
|
||||
|
||||
```sh
|
||||
pnpm eslint --prune-suppressions --pass-on-unpruned-suppressions <changed-files>
|
||||
```
|
||||
|
||||
- Never increase suppressions to bypass new code.
|
||||
- Prefer direct migration over adding suppression entries.
|
||||
|
||||
## React Refresh policy for base UI primitives
|
||||
|
||||
- We keep primitive aliases (for example `DropdownMenu = Menu.Root`) in the same module.
|
||||
- For `app/components/base/ui/**/*.tsx`, `react-refresh/only-export-components` is currently set to `off` in ESLint to avoid false positives and IDE noise during migration.
|
||||
- Do not use file-level `eslint-disable` comments for this policy; keep control in the scoped ESLint override.
|
||||
@@ -225,6 +225,38 @@ Simulate the interactions that matter to users—primary clicks, change events,
|
||||
|
||||
Mock the specific Next.js navigation hooks your component consumes (`useRouter`, `usePathname`, `useSearchParams`) and drive realistic routing flows—query parameters, redirects, guarded routes, URL updates—while asserting the rendered outcome or navigation side effects.
|
||||
|
||||
#### 7.1 `nuqs` Query State Testing
|
||||
|
||||
When testing code that uses `useQueryState` or `useQueryStates`, treat `nuqs` as the source of truth for URL synchronization.
|
||||
|
||||
- ✅ In runtime, keep `NuqsAdapter` in app layout (already wired in `app/layout.tsx`).
|
||||
- ✅ In tests, wrap with `NuqsTestingAdapter` (prefer helper utilities from `@/test/nuqs-testing`).
|
||||
- ✅ Assert URL behavior via `onUrlUpdate` events (`searchParams`, `options.history`) instead of only asserting router mocks.
|
||||
- ✅ For custom parsers created with `createParser`, keep `parse` and `serialize` bijective (round-trip safe). Add edge-case coverage for values like `%2F`, `%25`, spaces, and legacy encoded URLs.
|
||||
- ✅ Assert default-clearing behavior explicitly (`clearOnDefault` semantics remove params when value equals default).
|
||||
- ⚠️ Only mock `nuqs` directly when URL behavior is intentionally out of scope for the test. For ESM-safe partial mocks, use async `vi.mock` with `importOriginal`.
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
|
||||
it('should update query with push history', async () => {
|
||||
const { result, onUrlUpdate } = renderHookWithNuqs(() => useMyQueryState(), {
|
||||
searchParams: '?page=1',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setQuery({ page: 2 })
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const update = onUrlUpdate.mock.calls.at(-1)![0]
|
||||
expect(update.options.history).toBe('push')
|
||||
expect(update.searchParams.get('page')).toBe('2')
|
||||
})
|
||||
```
|
||||
|
||||
### 8. Edge Cases (REQUIRED - All Components)
|
||||
|
||||
**Must Test**:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import hyoban from 'eslint-plugin-hyoban'
|
||||
import sonar from 'eslint-plugin-sonarjs'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import dify from './eslint-rules/index.js'
|
||||
import { OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs'
|
||||
|
||||
// Enable Tailwind CSS IntelliSense mode for ESLint runs
|
||||
// See: tailwind-css-plugin.ts
|
||||
@@ -145,4 +146,51 @@ export default antfu(
|
||||
'hyoban/no-dependency-version-prefix': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dify/base-ui-primitives',
|
||||
files: ['app/components/base/ui/**/*.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dify/overlay-migration',
|
||||
files: [GLOB_TS, GLOB_TSX],
|
||||
ignores: [
|
||||
...GLOB_TESTS,
|
||||
...OVERLAY_MIGRATION_LEGACY_BASE_FILES,
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
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.',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
29
web/eslint.constants.mjs
Normal file
29
web/eslint.constants.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [
|
||||
'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx',
|
||||
'app/components/base/chat/chat-with-history/header/operation.tsx',
|
||||
'app/components/base/chat/chat-with-history/inputs-form/view-form-dropdown.tsx',
|
||||
'app/components/base/chat/chat-with-history/sidebar/operation.tsx',
|
||||
'app/components/base/chat/chat/citation/popup.tsx',
|
||||
'app/components/base/chat/chat/citation/progress-tooltip.tsx',
|
||||
'app/components/base/chat/chat/citation/tooltip.tsx',
|
||||
'app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown.tsx',
|
||||
'app/components/base/chip/index.tsx',
|
||||
'app/components/base/date-and-time-picker/date-picker/index.tsx',
|
||||
'app/components/base/date-and-time-picker/time-picker/index.tsx',
|
||||
'app/components/base/dropdown/index.tsx',
|
||||
'app/components/base/features/new-feature-panel/file-upload/setting-modal.tsx',
|
||||
'app/components/base/features/new-feature-panel/text-to-speech/voice-settings.tsx',
|
||||
'app/components/base/file-uploader/file-from-link-or-local/index.tsx',
|
||||
'app/components/base/image-uploader/chat-image-uploader.tsx',
|
||||
'app/components/base/image-uploader/text-generation-image-uploader.tsx',
|
||||
'app/components/base/modal/modal.tsx',
|
||||
'app/components/base/prompt-editor/plugins/context-block/component.tsx',
|
||||
'app/components/base/prompt-editor/plugins/history-block/component.tsx',
|
||||
'app/components/base/select/custom.tsx',
|
||||
'app/components/base/select/index.tsx',
|
||||
'app/components/base/select/pure.tsx',
|
||||
'app/components/base/sort/index.tsx',
|
||||
'app/components/base/tag-management/filter.tsx',
|
||||
'app/components/base/theme-selector.tsx',
|
||||
'app/components/base/tooltip/index.tsx',
|
||||
]
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
|
||||
import { renderHookWithNuqs } from '@/test/nuqs-testing'
|
||||
import {
|
||||
clearQueryParams,
|
||||
PRICING_MODAL_QUERY_PARAM,
|
||||
@@ -20,14 +18,7 @@ vi.mock('@/utils/client', () => ({
|
||||
}))
|
||||
|
||||
const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => {
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
const { result } = renderHook(hook, { wrapper })
|
||||
return { result, onUrlUpdate }
|
||||
return renderHookWithNuqs(hook, { searchParams })
|
||||
}
|
||||
|
||||
// Query param hooks: defaults, parsing, and URL sync behavior.
|
||||
|
||||
@@ -13,14 +13,19 @@
|
||||
* - Use shallow routing to avoid unnecessary re-renders
|
||||
*/
|
||||
|
||||
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
createParser,
|
||||
parseAsString,
|
||||
parseAsStringEnum,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
useQueryStates,
|
||||
} from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { ACCOUNT_SETTING_MODAL_ACTION } from '@/app/components/header/account-setting/constants'
|
||||
import {
|
||||
ACCOUNT_SETTING_MODAL_ACTION,
|
||||
ACCOUNT_SETTING_TAB,
|
||||
} from '@/app/components/header/account-setting/constants'
|
||||
import { isServer } from '@/utils/client'
|
||||
|
||||
/**
|
||||
@@ -52,6 +57,10 @@ export function usePricingModal() {
|
||||
)
|
||||
}
|
||||
|
||||
const accountSettingTabValues = Object.values(ACCOUNT_SETTING_TAB) as AccountSettingTab[]
|
||||
const parseAsAccountSettingAction = parseAsStringLiteral([ACCOUNT_SETTING_MODAL_ACTION] as const)
|
||||
const parseAsAccountSettingTab = parseAsStringEnum<AccountSettingTab>(accountSettingTabValues)
|
||||
|
||||
/**
|
||||
* Hook to manage account setting modal state via URL
|
||||
* @returns [state, setState] - Object with isOpen + payload (tab) and setter
|
||||
@@ -61,11 +70,11 @@ export function usePricingModal() {
|
||||
* setAccountModalState({ payload: 'billing' }) // Sets ?action=showSettings&tab=billing
|
||||
* setAccountModalState(null) // Removes both params
|
||||
*/
|
||||
export function useAccountSettingModal<T extends string = string>() {
|
||||
export function useAccountSettingModal() {
|
||||
const [accountState, setAccountState] = useQueryStates(
|
||||
{
|
||||
action: parseAsString,
|
||||
tab: parseAsString,
|
||||
action: parseAsAccountSettingAction,
|
||||
tab: parseAsAccountSettingTab,
|
||||
},
|
||||
{
|
||||
history: 'replace',
|
||||
@@ -73,7 +82,7 @@ export function useAccountSettingModal<T extends string = string>() {
|
||||
)
|
||||
|
||||
const setState = useCallback(
|
||||
(state: { payload: T } | null) => {
|
||||
(state: { payload: AccountSettingTab } | null) => {
|
||||
if (!state) {
|
||||
setAccountState({ action: null, tab: null }, { history: 'replace' })
|
||||
return
|
||||
@@ -88,7 +97,7 @@ export function useAccountSettingModal<T extends string = string>() {
|
||||
)
|
||||
|
||||
const isOpen = accountState.action === ACCOUNT_SETTING_MODAL_ACTION
|
||||
const currentTab = (isOpen ? accountState.tab : null) as T | null
|
||||
const currentTab = isOpen ? accountState.tab : null
|
||||
|
||||
return [{ isOpen, payload: currentTab }, setState] as const
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"and_qq >= 14.9"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
"node": "^22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -31,9 +31,9 @@
|
||||
"dev:vinext": "vinext dev",
|
||||
"build": "next build",
|
||||
"build:docker": "next build && node scripts/optimize-standalone.js",
|
||||
"build:vinext": "vinext build",
|
||||
"build:vinext": "cross-env NODE_ENV=production vinext build",
|
||||
"start": "node ./scripts/copy-and-start.mjs",
|
||||
"start:vinext": "vinext start",
|
||||
"start:vinext": "cross-env NODE_ENV=production vinext start",
|
||||
"lint": "eslint --cache --concurrency=auto",
|
||||
"lint:ci": "eslint --cache --concurrency 2",
|
||||
"lint:fix": "pnpm lint --fix",
|
||||
@@ -63,6 +63,7 @@
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "2.33.1",
|
||||
"@amplitude/plugin-session-replay-browser": "1.23.6",
|
||||
"@base-ui/react": "1.2.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@floating-ui/react": "0.26.28",
|
||||
"@formatjs/intl-localematcher": "0.5.10",
|
||||
@@ -247,7 +248,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"uglify-js": "3.19.3",
|
||||
"vinext": "https://pkg.pr.new/hyoban/vinext@cfae669",
|
||||
"vite": "7.3.1",
|
||||
"vite": "8.0.0-beta.16",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.0.18",
|
||||
"vitest-canvas-mock": "1.1.3"
|
||||
|
||||
516
web/pnpm-lock.yaml
generated
516
web/pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
||||
'@amplitude/plugin-session-replay-browser':
|
||||
specifier: 1.23.6
|
||||
version: 1.23.6(@amplitude/rrweb@2.0.0-alpha.35)(rollup@4.56.0)
|
||||
'@base-ui/react':
|
||||
specifier: 1.2.0
|
||||
version: 1.2.0(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@emoji-mart/data':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
@@ -372,7 +375,7 @@ importers:
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: 7.6.1
|
||||
version: 7.6.1(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.6)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.2(jiti@1.21.7)))(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 7.6.1(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.6)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.2(jiti@1.21.7)))(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@chromatic-com/storybook':
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
@@ -411,7 +414,7 @@ importers:
|
||||
version: 9.5.4(@swc/helpers@0.5.18)(esbuild-wasm@0.27.2)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react@19.2.4)(typescript@5.9.3)
|
||||
'@storybook/addon-docs':
|
||||
specifier: 10.2.13
|
||||
version: 10.2.13(@types/react@19.2.9)(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
version: 10.2.13(@types/react@19.2.9)(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/addon-links':
|
||||
specifier: 10.2.13
|
||||
version: 10.2.13(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
@@ -423,7 +426,7 @@ importers:
|
||||
version: 10.2.13(storybook@10.2.13(@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.13
|
||||
version: 10.2.13(@babel/core@7.29.0)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
version: 10.2.13(@babel/core@7.29.0)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/react':
|
||||
specifier: 10.2.13
|
||||
version: 10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
|
||||
@@ -510,13 +513,13 @@ importers:
|
||||
version: 7.0.0-dev.20251209.1
|
||||
'@vitejs/plugin-react':
|
||||
specifier: 5.1.4
|
||||
version: 5.1.4(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 5.1.4(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitejs/plugin-rsc':
|
||||
specifier: 0.5.21
|
||||
version: 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
autoprefixer:
|
||||
specifier: 10.4.21
|
||||
version: 10.4.21(postcss@8.5.6)
|
||||
@@ -606,19 +609,19 @@ importers:
|
||||
version: 3.19.3
|
||||
vinext:
|
||||
specifier: https://pkg.pr.new/hyoban/vinext@cfae669
|
||||
version: https://pkg.pr.new/hyoban/vinext@cfae669(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
version: https://pkg.pr.new/hyoban/vinext@cfae669(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
vite:
|
||||
specifier: 7.3.1
|
||||
version: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
specifier: 8.0.0-beta.16
|
||||
version: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-tsconfig-paths:
|
||||
specifier: 6.1.1
|
||||
version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 6.1.1(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vitest:
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
version: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest-canvas-mock:
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
version: 1.1.3(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -900,6 +903,27 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.2.0':
|
||||
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.2.5':
|
||||
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^17 || ^18 || ^19
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1978,6 +2002,13 @@ packages:
|
||||
resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@oxc-project/runtime@0.115.0':
|
||||
resolution: {integrity: sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
||||
'@oxc-project/types@0.115.0':
|
||||
resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.16.4':
|
||||
resolution: {integrity: sha512-6XUHilmj8D6Ggus+sTBp64x/DUQ7LgC/dvTDdUOt4iMQnDdSep6N1mnvVLIiG+qM5tRnNHravNzBJnUlYwRQoA==}
|
||||
cpu: [arm]
|
||||
@@ -2455,12 +2486,92 @@ packages:
|
||||
resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.3':
|
||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.5':
|
||||
resolution: {integrity: sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw==}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.6':
|
||||
resolution: {integrity: sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA==}
|
||||
|
||||
'@rollup/plugin-replace@6.0.3':
|
||||
resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5696,6 +5807,76 @@ packages:
|
||||
engines: {node: '>=16'}
|
||||
hasBin: true
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
lightningcss-darwin-arm64@1.31.1:
|
||||
resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.31.1:
|
||||
resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.31.1:
|
||||
resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.31.1:
|
||||
resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.31.1:
|
||||
resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.31.1:
|
||||
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lilconfig@3.1.3:
|
||||
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -6812,6 +6993,9 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
reserved-identifiers@1.2.0:
|
||||
resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6845,6 +7029,11 @@ packages:
|
||||
robust-predicates@3.0.2:
|
||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||
|
||||
rolldown@1.0.0-rc.6:
|
||||
resolution: {integrity: sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
rollup@4.56.0:
|
||||
resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
@@ -7614,6 +7803,49 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vite@8.0.0-beta.16:
|
||||
resolution: {integrity: sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
'@vitejs/devtools': ^0.0.0-alpha.31
|
||||
esbuild: 0.27.2
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitejs/devtools':
|
||||
optional: true
|
||||
esbuild:
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitefu@1.1.2:
|
||||
resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==}
|
||||
peerDependencies:
|
||||
@@ -8060,7 +8292,7 @@ snapshots:
|
||||
idb: 8.0.3
|
||||
tslib: 2.8.1
|
||||
|
||||
'@antfu/eslint-config@7.6.1(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.6)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.2(jiti@1.21.7)))(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@antfu/eslint-config@7.6.1(@eslint-react/eslint-plugin@2.13.0(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.6)(@typescript-eslint/rule-tester@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3))(@typescript-eslint/utils@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@10.0.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.5.2(eslint@10.0.2(jiti@1.21.7)))(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 1.1.0
|
||||
'@clack/prompts': 1.0.1
|
||||
@@ -8069,7 +8301,7 @@ snapshots:
|
||||
'@stylistic/eslint-plugin': https://pkg.pr.new/@stylistic/eslint-plugin@258f9d8(eslint@10.0.2(jiti@1.21.7))
|
||||
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3))(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
'@vitest/eslint-plugin': 1.6.9(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/eslint-plugin': 1.6.9(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
ansis: 4.2.0
|
||||
cac: 6.7.14
|
||||
eslint: 10.0.2(jiti@1.21.7)
|
||||
@@ -8316,6 +8548,30 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@base-ui/react@1.2.0(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@base-ui/utils': 0.2.5(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
tabbable: 6.4.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.9
|
||||
|
||||
'@base-ui/utils@0.2.5(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@floating-ui/utils': 0.2.10
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.9
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1': {}
|
||||
@@ -9037,11 +9293,11 @@ snapshots:
|
||||
dependencies:
|
||||
minipass: 7.1.3
|
||||
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
glob: 13.0.6
|
||||
react-docgen-typescript: 2.4.0(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
@@ -9532,6 +9788,10 @@ snapshots:
|
||||
|
||||
'@ota-meshi/ast-token-store@0.3.0': {}
|
||||
|
||||
'@oxc-project/runtime@0.115.0': {}
|
||||
|
||||
'@oxc-project/types@0.115.0': {}
|
||||
|
||||
'@oxc-resolver/binding-android-arm-eabi@11.16.4':
|
||||
optional: true
|
||||
|
||||
@@ -9961,10 +10221,53 @@ snapshots:
|
||||
|
||||
'@rgrove/parse-xml@4.2.0': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-arm64@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-darwin-x64@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-freebsd-x64@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-gnu@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-wasm32-wasi@1.0.0-rc.6':
|
||||
dependencies:
|
||||
'@napi-rs/wasm-runtime': 1.1.1
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-x64-msvc@1.0.0-rc.6':
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.5': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.6': {}
|
||||
|
||||
'@rollup/plugin-replace@6.0.3(rollup@4.56.0)':
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.56.0)
|
||||
@@ -10181,10 +10484,10 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@storybook/addon-docs@10.2.13(@types/react@19.2.9)(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
'@storybook/addon-docs@10.2.13(@types/react@19.2.9)(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
dependencies:
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.2.9)(react@19.2.4)
|
||||
'@storybook/csf-plugin': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/csf-plugin': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(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.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
|
||||
react: 19.2.4
|
||||
@@ -10214,25 +10517,25 @@ snapshots:
|
||||
storybook: 10.2.13(@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.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
'@storybook/builder-vite@10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
dependencies:
|
||||
'@storybook/csf-plugin': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/csf-plugin': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
storybook: 10.2.13(@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: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- esbuild
|
||||
- rollup
|
||||
- webpack
|
||||
|
||||
'@storybook/csf-plugin@10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
'@storybook/csf-plugin@10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
dependencies:
|
||||
storybook: 10.2.13(@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
|
||||
rollup: 4.56.0
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
webpack: 5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)
|
||||
|
||||
'@storybook/global@5.0.0': {}
|
||||
@@ -10242,18 +10545,18 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@storybook/nextjs-vite@10.2.13(@babel/core@7.29.0)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
'@storybook/nextjs-vite@10.2.13(@babel/core@7.29.0)(esbuild@0.27.2)(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
dependencies:
|
||||
'@storybook/builder-vite': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/builder-vite': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/react': 10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@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.13(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/react-vite': 10.2.13(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
next: 16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.2.13(@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: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-storybook-nextjs: 3.2.2(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-storybook-nextjs: 3.2.2(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
@@ -10270,11 +10573,11 @@ snapshots:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
storybook: 10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
||||
'@storybook/react-vite@10.2.13(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
'@storybook/react-vite@10.2.13(esbuild@0.27.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))':
|
||||
dependencies:
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.56.0)
|
||||
'@storybook/builder-vite': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/builder-vite': 10.2.13(esbuild@0.27.2)(rollup@4.56.0)(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
'@storybook/react': 10.2.13(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.13(@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
|
||||
@@ -10284,7 +10587,7 @@ snapshots:
|
||||
resolve: 1.22.11
|
||||
storybook: 10.2.13(@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: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- esbuild
|
||||
- rollup
|
||||
@@ -11120,7 +11423,7 @@ snapshots:
|
||||
'@resvg/resvg-wasm': 2.4.0
|
||||
satori: 0.16.0
|
||||
|
||||
'@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitejs/plugin-react@5.1.4(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||
@@ -11128,11 +11431,11 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.3
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.18.0
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitejs/plugin-rsc@0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitejs/plugin-rsc@0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.5
|
||||
es-module-lexer: 2.0.0
|
||||
@@ -11144,12 +11447,12 @@ snapshots:
|
||||
srvx: 0.11.7
|
||||
strip-literal: 3.1.0
|
||||
turbo-stream: 3.1.0
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitefu: 1.1.2(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitefu: 1.1.2(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
optionalDependencies:
|
||||
react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
@@ -11161,16 +11464,16 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/eslint-plugin@1.6.9(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/eslint-plugin@1.6.9(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.56.1
|
||||
'@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@1.21.7))(typescript@5.9.3)
|
||||
eslint: 10.0.2(jiti@1.21.7)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -11191,13 +11494,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -13584,6 +13887,55 @@ snapshots:
|
||||
dependencies:
|
||||
isomorphic.js: 0.2.5
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.31.1:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.31.1:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.31.1
|
||||
lightningcss-darwin-arm64: 1.31.1
|
||||
lightningcss-darwin-x64: 1.31.1
|
||||
lightningcss-freebsd-x64: 1.31.1
|
||||
lightningcss-linux-arm-gnueabihf: 1.31.1
|
||||
lightningcss-linux-arm64-gnu: 1.31.1
|
||||
lightningcss-linux-arm64-musl: 1.31.1
|
||||
lightningcss-linux-x64-gnu: 1.31.1
|
||||
lightningcss-linux-x64-musl: 1.31.1
|
||||
lightningcss-win32-arm64-msvc: 1.31.1
|
||||
lightningcss-win32-x64-msvc: 1.31.1
|
||||
|
||||
lilconfig@3.1.3: {}
|
||||
|
||||
linebreak@1.1.0:
|
||||
@@ -15127,6 +15479,8 @@ snapshots:
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
reserved-identifiers@1.2.0: {}
|
||||
|
||||
resize-observer-polyfill@1.5.1: {}
|
||||
@@ -15152,6 +15506,25 @@ snapshots:
|
||||
|
||||
robust-predicates@3.0.2: {}
|
||||
|
||||
rolldown@1.0.0-rc.6:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.115.0
|
||||
'@rolldown/pluginutils': 1.0.0-rc.6
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm64': 1.0.0-rc.6
|
||||
'@rolldown/binding-darwin-arm64': 1.0.0-rc.6
|
||||
'@rolldown/binding-darwin-x64': 1.0.0-rc.6
|
||||
'@rolldown/binding-freebsd-x64': 1.0.0-rc.6
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.6
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.6
|
||||
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.6
|
||||
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.6
|
||||
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.6
|
||||
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.6
|
||||
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.6
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.6
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.6
|
||||
|
||||
rollup@4.56.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -15918,19 +16291,19 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vinext@https://pkg.pr.new/hyoban/vinext@cfae669(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
|
||||
vinext@https://pkg.pr.new/hyoban/vinext@cfae669(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)):
|
||||
dependencies:
|
||||
'@unpic/react': 1.0.2(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@vercel/og': 0.8.6
|
||||
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitejs/plugin-rsc': 0.5.21(react-dom@19.2.4(react@19.2.4))(react-server-dom-webpack@19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(react@19.2.4)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
magic-string: 0.30.21
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-server-dom-webpack: 19.2.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
|
||||
rsc-html-stream: 0.0.7
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-plugin-commonjs: 0.10.4
|
||||
vite-tsconfig-paths: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite-tsconfig-paths: 6.1.1(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
transitivePeerDependencies:
|
||||
- next
|
||||
- supports-color
|
||||
@@ -15950,7 +16323,7 @@ snapshots:
|
||||
fast-glob: 3.3.3
|
||||
magic-string: 0.30.21
|
||||
|
||||
vite-plugin-storybook-nextjs@3.2.2(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
vite-plugin-storybook-nextjs@3.2.2(next@16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2))(storybook@10.2.13(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
'@next/env': 16.0.0
|
||||
image-size: 2.0.2
|
||||
@@ -15959,34 +16332,34 @@ snapshots:
|
||||
next: 16.1.5(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.93.2)
|
||||
storybook: 10.2.13(@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: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.6(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -15998,25 +16371,44 @@ snapshots:
|
||||
'@types/node': 24.10.12
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
lightningcss: 1.31.1
|
||||
sass: 1.93.2
|
||||
terser: 5.46.0
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitefu@1.1.2(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.115.0
|
||||
lightningcss: 1.31.1
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rolldown: 1.0.0-rc.6
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
'@types/node': 24.10.12
|
||||
esbuild: 0.27.2
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
sass: 1.93.2
|
||||
terser: 5.46.0
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest-canvas-mock@1.1.3(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
vitefu@1.1.2(vite@8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
optionalDependencies:
|
||||
vite: 8.0.0-beta.16(@types/node@24.10.12)(esbuild@0.27.2)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
vitest-canvas-mock@1.1.3(vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)):
|
||||
dependencies:
|
||||
cssfontparser: 1.2.1
|
||||
moo-color: 1.0.3
|
||||
vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vitest: 4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
vitest@4.0.18(@types/node@24.10.12)(jiti@1.21.7)(jsdom@27.3.0(canvas@3.2.1))(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.12)(jiti@1.21.7)(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
@@ -16033,7 +16425,7 @@ snapshots:
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
vite: 7.3.1(@types/node@24.10.12)(jiti@1.21.7)(lightningcss@1.31.1)(sass@1.93.2)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.10.12
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { UseQueryOptions } from '@tanstack/react-query'
|
||||
import type { DocumentDownloadResponse, DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets'
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
} from '@tanstack/react-query'
|
||||
@@ -14,6 +16,8 @@ import { useInvalid } from '../use-base'
|
||||
const NAME_SPACE = 'knowledge/document'
|
||||
|
||||
export const useDocumentListKey = [NAME_SPACE, 'documentList']
|
||||
type DocumentListRefetchInterval = UseQueryOptions<DocumentListResponse>['refetchInterval']
|
||||
|
||||
export const useDocumentList = (payload: {
|
||||
datasetId: string
|
||||
query: {
|
||||
@@ -23,7 +27,7 @@ export const useDocumentList = (payload: {
|
||||
sort?: SortType
|
||||
status?: string
|
||||
}
|
||||
refetchInterval?: number | false
|
||||
refetchInterval?: DocumentListRefetchInterval
|
||||
}) => {
|
||||
const { query, datasetId, refetchInterval } = payload
|
||||
const { keyword, page, limit, sort, status } = query
|
||||
@@ -42,6 +46,7 @@ export const useDocumentList = (payload: {
|
||||
queryFn: () => get<DocumentListResponse>(`/datasets/${datasetId}/documents`, {
|
||||
params,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
60
web/test/nuqs-testing.tsx
Normal file
60
web/test/nuqs-testing.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||
import type { ComponentProps, ReactElement, ReactNode } from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, renderHook } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
type NuqsSearchParams = ComponentProps<typeof NuqsTestingAdapter>['searchParams']
|
||||
type NuqsOnUrlUpdate = (event: UrlUpdateEvent) => void
|
||||
type NuqsOnUrlUpdateSpy = Mock<NuqsOnUrlUpdate>
|
||||
|
||||
type NuqsTestOptions = {
|
||||
searchParams?: NuqsSearchParams
|
||||
onUrlUpdate?: NuqsOnUrlUpdateSpy
|
||||
}
|
||||
|
||||
type NuqsHookTestOptions<Props> = NuqsTestOptions & {
|
||||
initialProps?: Props
|
||||
}
|
||||
|
||||
type NuqsWrapperProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const createNuqsTestWrapper = (options: NuqsTestOptions = {}) => {
|
||||
const { searchParams = '', onUrlUpdate } = options
|
||||
const urlUpdateSpy = onUrlUpdate ?? vi.fn<NuqsOnUrlUpdate>()
|
||||
const wrapper = ({ children }: NuqsWrapperProps) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={urlUpdateSpy}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
)
|
||||
|
||||
return {
|
||||
wrapper,
|
||||
onUrlUpdate: urlUpdateSpy,
|
||||
}
|
||||
}
|
||||
|
||||
export const renderWithNuqs = (ui: ReactElement, options: NuqsTestOptions = {}) => {
|
||||
const { wrapper, onUrlUpdate } = createNuqsTestWrapper(options)
|
||||
const rendered = render(ui, { wrapper })
|
||||
return {
|
||||
...rendered,
|
||||
onUrlUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
export const renderHookWithNuqs = <Result, Props = void>(
|
||||
callback: (props: Props) => Result,
|
||||
options: NuqsHookTestOptions<Props> = {},
|
||||
) => {
|
||||
const { initialProps, ...nuqsOptions } = options
|
||||
const { wrapper, onUrlUpdate } = createNuqsTestWrapper(nuqsOptions)
|
||||
const rendered = renderHook(callback, { wrapper, initialProps })
|
||||
return {
|
||||
...rendered,
|
||||
onUrlUpdate,
|
||||
}
|
||||
}
|
||||
@@ -108,10 +108,30 @@ export default defineConfig(({ mode }) => {
|
||||
? {
|
||||
optimizeDeps: {
|
||||
exclude: ['nuqs'],
|
||||
// Make Prism in lexical works
|
||||
// https://github.com/vitejs/rolldown-vite/issues/396
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
strictExecutionOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
ssr: {
|
||||
// SyntaxError: Named export not found. The requested module is a CommonJS module, which may not support all module.exports as named exports
|
||||
noExternal: ['emoji-mart'],
|
||||
},
|
||||
// Make Prism in lexical works
|
||||
// https://github.com/vitejs/rolldown-vite/issues/396
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
strictExecutionOrder: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user