mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 09:55:09 +00:00
Compare commits
39 Commits
review-mys
...
1f864fe8e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f864fe8e7 | ||
|
|
4c48e3b997 | ||
|
|
46f0cebbb0 | ||
|
|
2d54192f35 | ||
|
|
80a5398dea | ||
|
|
ab64c4adf9 | ||
|
|
ce8354a42a | ||
|
|
d0bb642fc5 | ||
|
|
e4ddf07194 | ||
|
|
aad980f267 | ||
|
|
8141e3af99 | ||
|
|
b108de6607 | ||
|
|
7b3b3dbe52 | ||
|
|
5d7aeaa7e5 | ||
|
|
41e2812349 | ||
|
|
53048feb9f | ||
|
|
fbacb9f7a2 | ||
|
|
4d36a0707a | ||
|
|
3c4f5b45c4 | ||
|
|
ce75f26744 | ||
|
|
ea0e1b52a8 | ||
|
|
0993b94acd | ||
|
|
368db04519 | ||
|
|
4e3680e139 | ||
|
|
3758904c00 | ||
|
|
c019916494 | ||
|
|
938e4790f4 | ||
|
|
1f1456f3b9 | ||
|
|
00591a592c | ||
|
|
756e4b8e37 | ||
|
|
98ee6a7620 | ||
|
|
6456efbe41 | ||
|
|
3f2b2c199f | ||
|
|
b27b34674b | ||
|
|
9487daf71c | ||
|
|
e80bd15d5c | ||
|
|
a8ddc1408e | ||
|
|
5bb4110f85 | ||
|
|
b0bae39696 |
@@ -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
|
||||
|
||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -10,10 +10,14 @@ updates:
|
||||
directory: "/api"
|
||||
open-pull-requests-limit: 2
|
||||
patterns: ["*"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/api"
|
||||
open-pull-requests-limit: 2
|
||||
patterns: ["*"]
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/web"
|
||||
schedule:
|
||||
|
||||
@@ -10,7 +10,7 @@ import services
|
||||
from controllers.common.fields import Parameters as ParametersResponse
|
||||
from controllers.common.fields import Site as SiteResponse
|
||||
from controllers.common.schema import get_or_create_model
|
||||
from controllers.console import api, console_ns
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
AudioTooLargeError,
|
||||
@@ -469,7 +469,7 @@ class TrialSitApi(Resource):
|
||||
"""Resource for trial app sites."""
|
||||
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
def get(self, app_model):
|
||||
"""Retrieve app site info.
|
||||
|
||||
@@ -491,7 +491,7 @@ class TrialAppParameterApi(Resource):
|
||||
"""Resource for app variables."""
|
||||
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
def get(self, app_model):
|
||||
"""Retrieve app parameters."""
|
||||
|
||||
@@ -520,7 +520,7 @@ class TrialAppParameterApi(Resource):
|
||||
|
||||
class AppApi(Resource):
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
@marshal_with(app_detail_with_site_model)
|
||||
def get(self, app_model):
|
||||
"""Get app detail"""
|
||||
@@ -533,7 +533,7 @@ class AppApi(Resource):
|
||||
|
||||
class AppWorkflowApi(Resource):
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
@marshal_with(workflow_model)
|
||||
def get(self, app_model):
|
||||
"""Get workflow detail"""
|
||||
@@ -552,7 +552,7 @@ class AppWorkflowApi(Resource):
|
||||
|
||||
class DatasetListApi(Resource):
|
||||
@trial_feature_enable
|
||||
@get_app_model_with_trial
|
||||
@get_app_model_with_trial(None)
|
||||
def get(self, app_model):
|
||||
page = request.args.get("page", default=1, type=int)
|
||||
limit = request.args.get("limit", default=20, type=int)
|
||||
@@ -570,27 +570,31 @@ class DatasetListApi(Resource):
|
||||
return response
|
||||
|
||||
|
||||
api.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
|
||||
console_ns.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
|
||||
|
||||
api.add_resource(
|
||||
console_ns.add_resource(
|
||||
TrialMessageSuggestedQuestionApi,
|
||||
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
|
||||
endpoint="trial_app_suggested_question",
|
||||
)
|
||||
|
||||
api.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
|
||||
api.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
|
||||
console_ns.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
|
||||
console_ns.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
|
||||
|
||||
api.add_resource(TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion")
|
||||
console_ns.add_resource(
|
||||
TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion"
|
||||
)
|
||||
|
||||
api.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
|
||||
console_ns.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
|
||||
|
||||
api.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
|
||||
console_ns.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
|
||||
|
||||
api.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
|
||||
console_ns.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
|
||||
|
||||
api.add_resource(TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run")
|
||||
api.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
|
||||
console_ns.add_resource(
|
||||
TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run"
|
||||
)
|
||||
console_ns.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
|
||||
|
||||
api.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
|
||||
api.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")
|
||||
console_ns.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
|
||||
console_ns.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")
|
||||
|
||||
@@ -105,9 +105,9 @@ def trial_app_required(view: Callable[Concatenate[App, P], R] | None = None):
|
||||
return decorator
|
||||
|
||||
|
||||
def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]:
|
||||
def trial_feature_enable(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_trial_app:
|
||||
abort(403, "Trial app feature is not enabled.")
|
||||
@@ -116,9 +116,9 @@ def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]:
|
||||
return decorated
|
||||
|
||||
|
||||
def explore_banner_enabled(view: Callable[..., R]) -> Callable[..., R]:
|
||||
def explore_banner_enabled(view: Callable[P, R]):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_explore_banner:
|
||||
abort(403, "Explore banner feature is not enabled.")
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from abc import ABC, abstractmethod
|
||||
from enum import IntEnum, auto
|
||||
from typing import Any
|
||||
|
||||
@@ -31,7 +31,7 @@ class PublishFrom(IntEnum):
|
||||
TASK_PIPELINE = auto()
|
||||
|
||||
|
||||
class AppQueueManager:
|
||||
class AppQueueManager(ABC):
|
||||
def __init__(self, task_id: str, user_id: str, invoke_from: InvokeFrom):
|
||||
if not user_id:
|
||||
raise ValueError("user is required")
|
||||
|
||||
@@ -366,7 +366,9 @@ class Executor:
|
||||
**request_args,
|
||||
max_retries=self.max_retries,
|
||||
)
|
||||
except (self._http_client.max_retries_exceeded_error, self._http_client.request_error) as e:
|
||||
except self._http_client.max_retries_exceeded_error as e:
|
||||
raise HttpRequestNodeError(f"Reached maximum retries for URL {self.url}") from e
|
||||
except self._http_client.request_error as e:
|
||||
raise HttpRequestNodeError(str(e)) from e
|
||||
return response
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ dependencies = [
|
||||
"flask-orjson~=2.0.0",
|
||||
"flask-sqlalchemy~=3.1.1",
|
||||
"gevent~=25.9.1",
|
||||
"gmpy2~=2.2.1",
|
||||
"gmpy2~=2.3.0",
|
||||
"google-api-core==2.18.0",
|
||||
"google-api-python-client==2.189.0",
|
||||
"google-auth==2.29.0",
|
||||
@@ -65,16 +65,16 @@ dependencies = [
|
||||
"psycogreen~=1.0.2",
|
||||
"psycopg2-binary~=2.9.6",
|
||||
"pycryptodome==3.23.0",
|
||||
"pydantic~=2.11.4",
|
||||
"pydantic~=2.12.5",
|
||||
"pydantic-extra-types~=2.10.3",
|
||||
"pydantic-settings~=2.12.0",
|
||||
"pyjwt~=2.10.1",
|
||||
"pypdfium2==5.2.0",
|
||||
"python-docx~=1.1.0",
|
||||
"python-docx~=1.2.0",
|
||||
"python-dotenv==1.0.1",
|
||||
"pyyaml~=6.0.1",
|
||||
"readabilipy~=0.3.0",
|
||||
"redis[hiredis]~=6.1.0",
|
||||
"redis[hiredis]~=7.2.0",
|
||||
"resend~=2.9.0",
|
||||
"sentry-sdk[flask]~=2.28.0",
|
||||
"sqlalchemy~=2.0.29",
|
||||
@@ -138,7 +138,7 @@ dev = [
|
||||
"types-gevent~=25.9.0",
|
||||
"types-greenlet~=3.3.0",
|
||||
"types-html5lib~=1.1.11",
|
||||
"types-markdown~=3.7.0",
|
||||
"types-markdown~=3.10.2",
|
||||
"types-oauthlib~=3.2.0",
|
||||
"types-objgraph~=3.6.0",
|
||||
"types-olefile~=0.47.0",
|
||||
@@ -211,7 +211,7 @@ vdb = [
|
||||
"clickzetta-connector-python>=0.8.102",
|
||||
"couchbase~=4.3.0",
|
||||
"elasticsearch==8.14.0",
|
||||
"opensearch-py==2.4.0",
|
||||
"opensearch-py==3.1.0",
|
||||
"oracledb==3.3.0",
|
||||
"pgvecto-rs[sqlalchemy]~=0.2.1",
|
||||
"pgvector==0.2.5",
|
||||
|
||||
@@ -3,13 +3,15 @@ from collections.abc import Mapping, Sequence
|
||||
from mimetypes import guess_type
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from yarl import URL
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper import marketplace
|
||||
from core.helper.download import download_with_size_limit
|
||||
from core.helper.marketplace import download_plugin_pkg
|
||||
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
|
||||
from core.plugin.entities.bundle import PluginBundleDependency
|
||||
from core.plugin.entities.plugin import (
|
||||
PluginDeclaration,
|
||||
@@ -28,7 +30,7 @@ from core.plugin.impl.debugging import PluginDebuggingClient
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.provider import ProviderCredential
|
||||
from models.provider import Provider, ProviderCredential
|
||||
from models.provider_ids import GenericProviderID
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
@@ -511,30 +513,55 @@ class PluginService:
|
||||
manager = PluginInstaller()
|
||||
|
||||
# Get plugin info before uninstalling to delete associated credentials
|
||||
try:
|
||||
plugins = manager.list_plugins(tenant_id)
|
||||
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
|
||||
plugins = manager.list_plugins(tenant_id)
|
||||
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
|
||||
|
||||
if plugin:
|
||||
plugin_id = plugin.plugin_id
|
||||
logger.info("Deleting credentials for plugin: %s", plugin_id)
|
||||
if not plugin:
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
|
||||
# Delete provider credentials that match this plugin
|
||||
credentials = db.session.scalars(
|
||||
select(ProviderCredential).where(
|
||||
ProviderCredential.tenant_id == tenant_id,
|
||||
ProviderCredential.provider_name.like(f"{plugin_id}/%"),
|
||||
)
|
||||
).all()
|
||||
with Session(db.engine) as session, session.begin():
|
||||
plugin_id = plugin.plugin_id
|
||||
logger.info("Deleting credentials for plugin: %s", plugin_id)
|
||||
|
||||
for cred in credentials:
|
||||
db.session.delete(cred)
|
||||
# Delete provider credentials that match this plugin
|
||||
credential_ids = session.scalars(
|
||||
select(ProviderCredential.id).where(
|
||||
ProviderCredential.tenant_id == tenant_id,
|
||||
ProviderCredential.provider_name.like(f"{plugin_id}/%"),
|
||||
)
|
||||
).all()
|
||||
|
||||
db.session.commit()
|
||||
logger.info("Deleted %d credentials for plugin: %s", len(credentials), plugin_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to delete credentials: %s", e)
|
||||
# Continue with uninstall even if credential deletion fails
|
||||
if not credential_ids:
|
||||
logger.info("No credentials found for plugin: %s", plugin_id)
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
|
||||
provider_ids = session.scalars(
|
||||
select(Provider.id).where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_name.like(f"{plugin_id}/%"),
|
||||
Provider.credential_id.in_(credential_ids),
|
||||
)
|
||||
).all()
|
||||
|
||||
session.execute(update(Provider).where(Provider.id.in_(provider_ids)).values(credential_id=None))
|
||||
|
||||
for provider_id in provider_ids:
|
||||
ProviderCredentialsCache(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
).delete()
|
||||
|
||||
session.execute(
|
||||
delete(ProviderCredential).where(
|
||||
ProviderCredential.id.in_(credential_ids),
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Completed deleting credentials and cleaning provider associations for plugin: %s",
|
||||
plugin_id,
|
||||
)
|
||||
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
|
||||
|
||||
295
api/uv.lock
generated
295
api/uv.lock
generated
@@ -1590,7 +1590,7 @@ requires-dist = [
|
||||
{ name = "flask-restx", specifier = "~=1.3.2" },
|
||||
{ name = "flask-sqlalchemy", specifier = "~=3.1.1" },
|
||||
{ name = "gevent", specifier = "~=25.9.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.2.1" },
|
||||
{ name = "gmpy2", specifier = "~=2.3.0" },
|
||||
{ name = "google-api-core", specifier = "==2.18.0" },
|
||||
{ name = "google-api-python-client", specifier = "==2.189.0" },
|
||||
{ name = "google-auth", specifier = "==2.29.0" },
|
||||
@@ -1633,16 +1633,16 @@ requires-dist = [
|
||||
{ name = "psycogreen", specifier = "~=1.0.2" },
|
||||
{ name = "psycopg2-binary", specifier = "~=2.9.6" },
|
||||
{ name = "pycryptodome", specifier = "==3.23.0" },
|
||||
{ name = "pydantic", specifier = "~=2.11.4" },
|
||||
{ name = "pydantic", specifier = "~=2.12.5" },
|
||||
{ name = "pydantic-extra-types", specifier = "~=2.10.3" },
|
||||
{ name = "pydantic-settings", specifier = "~=2.12.0" },
|
||||
{ name = "pyjwt", specifier = "~=2.10.1" },
|
||||
{ name = "pypdfium2", specifier = "==5.2.0" },
|
||||
{ name = "python-docx", specifier = "~=1.1.0" },
|
||||
{ name = "python-docx", specifier = "~=1.2.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.0.1" },
|
||||
{ name = "pyyaml", specifier = "~=6.0.1" },
|
||||
{ name = "readabilipy", specifier = "~=0.3.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=6.1.0" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = "~=7.2.0" },
|
||||
{ name = "resend", specifier = "~=2.9.0" },
|
||||
{ name = "sendgrid", specifier = "~=6.12.3" },
|
||||
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.28.0" },
|
||||
@@ -1698,7 +1698,7 @@ dev = [
|
||||
{ name = "types-html5lib", specifier = "~=1.1.11" },
|
||||
{ name = "types-jmespath", specifier = ">=1.0.2.20240106" },
|
||||
{ name = "types-jsonschema", specifier = "~=4.23.0" },
|
||||
{ name = "types-markdown", specifier = "~=3.7.0" },
|
||||
{ name = "types-markdown", specifier = "~=3.10.2" },
|
||||
{ name = "types-oauthlib", specifier = "~=3.2.0" },
|
||||
{ name = "types-objgraph", specifier = "~=3.6.0" },
|
||||
{ name = "types-olefile", specifier = "~=0.47.0" },
|
||||
@@ -1750,7 +1750,7 @@ vdb = [
|
||||
{ name = "intersystems-irispython", specifier = ">=5.1.0" },
|
||||
{ name = "mo-vector", specifier = "~=0.1.13" },
|
||||
{ name = "mysql-connector-python", specifier = ">=9.3.0" },
|
||||
{ name = "opensearch-py", specifier = "==2.4.0" },
|
||||
{ name = "opensearch-py", specifier = "==3.1.0" },
|
||||
{ name = "oracledb", specifier = "==3.3.0" },
|
||||
{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" },
|
||||
{ name = "pgvector", specifier = "==0.2.5" },
|
||||
@@ -1896,6 +1896,14 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/d8/2a1c638d9e0aa7e269269a1a1bf423ddd94267f1a01bbe3ad03432b67dd4/eval_type_backport-0.3.0-py3-none-any.whl", hash = "sha256:975a10a0fe333c8b6260d7fdb637698c9a16c3a9e3b6eb943fee6a6f67a37fe8", size = 6061, upload-time = "2025-11-13T20:56:49.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "events"
|
||||
version = "0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/25/ed/e47dec0626edd468c84c04d97769e7ab4ea6457b7f54dcb3f72b17fcd876/Events-0.5-py3-none-any.whl", hash = "sha256:a7286af378ba3e46640ac9825156c93bdba7502174dd696090fdfcd4d80a1abd", size = 6758, upload-time = "2023-07-31T08:23:13.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "execnet"
|
||||
version = "2.1.2"
|
||||
@@ -2011,7 +2019,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
@@ -2021,9 +2029,9 @@ dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2248,24 +2256,31 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gmpy2"
|
||||
version = "2.2.1"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/bd/c6c154ce734a3e6187871b323297d8e5f3bdf9feaafc5212381538bc19e4/gmpy2-2.2.1.tar.gz", hash = "sha256:e83e07567441b78cb87544910cb3cc4fe94e7da987e93ef7622e76fb96650432", size = 234228, upload-time = "2024-07-21T05:33:00.715Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/57/86fd2ed7722cddfc7b1aa87cc768ef89944aa759b019595765aff5ad96a7/gmpy2-2.3.0.tar.gz", hash = "sha256:2d943cc9051fcd6b15b2a09369e2f7e18c526bc04c210782e4da61b62495eb4a", size = 302252, upload-time = "2026-02-08T00:57:42.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ec/ab67751ac0c4088ed21cf9a2a7f9966bf702ca8ebfc3204879cf58c90179/gmpy2-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:98e947491c67523d3147a500f377bb64d0b115e4ab8a12d628fb324bb0e142bf", size = 880346, upload-time = "2024-07-21T05:31:25.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/7c/bdc4a7a2b0e543787a9354e80fdcf846c4e9945685218cef4ca938d25594/gmpy2-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ccd319a3a87529484167ae1391f937ac4a8724169fd5822bbb541d1eab612b0", size = 694518, upload-time = "2024-07-21T05:31:27.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/44/ea903003bb4c3af004912fb0d6488e346bd76968f11a7472a1e60dee7dd7/gmpy2-2.2.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:827bcd433e5d62f1b732f45e6949419da4a53915d6c80a3c7a5a03d5a783a03a", size = 1653491, upload-time = "2024-07-21T05:31:29.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/5bce281b7cd664c04f1c9d47a37087db37b2be887bce738340e912ad86c8/gmpy2-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7131231fc96f57272066295c81cbf11b3233a9471659bca29ddc90a7bde9bfa", size = 1706487, upload-time = "2024-07-21T05:31:32.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/52/1f773571f21cf0319fc33218a1b384f29de43053965c05ed32f7e6729115/gmpy2-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1cc6f2bb68ee00c20aae554e111dc781a76140e00c31e4eda5c8f2d4168ed06c", size = 1637415, upload-time = "2024-07-21T05:31:34.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/4c/390daf67c221b3f4f10b5b7d9293e61e4dbd48956a38947679c5a701af27/gmpy2-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae388fe46e3d20af4675451a4b6c12fc1bb08e6e0e69ee47072638be21bf42d8", size = 1657781, upload-time = "2024-07-21T05:31:36.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/cd/86e47bccb3636389e29c4654a0e5ac52926d832897f2f64632639b63ffc1/gmpy2-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:8b472ee3c123b77979374da2293ebf2c170b88212e173d64213104956d4678fb", size = 1203346, upload-time = "2024-07-21T05:31:39.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/ee/8f9f65e2bac334cfe13b3fc3f8962d5fc2858ebcf4517690d2d24afa6d0e/gmpy2-2.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:90d03a1be1b1ad3944013fae5250316c3f4e6aec45ecdf189a5c7422d640004d", size = 885231, upload-time = "2024-07-21T05:31:41.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/1c/bf29f6bf8acd72c3cf85d04e7db1bb26dd5507ee2387770bb787bc54e2a5/gmpy2-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd09dd43d199908c1d1d501c5de842b3bf754f99b94af5b5ef0e26e3b716d2d5", size = 696569, upload-time = "2024-07-21T05:31:43.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/cc/38d33eadeccd81b604a95b67d43c71b246793b7c441f1d7c3b41978cd1cf/gmpy2-2.2.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3232859fda3e96fd1aecd6235ae20476ed4506562bcdef6796a629b78bb96acd", size = 1655776, upload-time = "2024-07-21T05:31:46.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/8d/d017599d6db8e9b96d6e84ea5102c33525cb71c82876b1813a2ece5d94ec/gmpy2-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30fba6f7cf43fb7f8474216701b5aaddfa5e6a06d560e88a67f814062934e863", size = 1707529, upload-time = "2024-07-21T05:31:48.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/93/91b4a0af23ae4216fd7ebcfd955dcbe152c5ef170598aee421310834de0a/gmpy2-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9b33cae533ede8173bc7d4bb855b388c5b636ca9f22a32c949f2eb7e0cc531b2", size = 1634195, upload-time = "2024-07-21T05:31:50.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ba/08ee99f19424cd33d5f0f17b2184e34d2fa886eebafcd3e164ccba15d9f2/gmpy2-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:954e7e1936c26e370ca31bbd49729ebeeb2006a8f9866b1e778ebb89add2e941", size = 1656779, upload-time = "2024-07-21T05:31:53.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e1/7b32ae2b23c8363d87b7f4bbac9abe9a1f820c2417d2e99ca3b4afd9379b/gmpy2-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c929870137b20d9c3f7dd97f43615b2d2c1a2470e50bafd9a5eea2e844f462e9", size = 1204668, upload-time = "2024-07-21T05:31:56.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/70/0b5bde5f8e960c25ee18a352eb12bf5078d7fff3367c86d04985371de3f5/gmpy2-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2792ec96b2c4ee5af9f72409cd5b786edaf8277321f7022ce80ddff265815b01", size = 858392, upload-time = "2026-02-08T00:56:06.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9b/2b52e92d0f1f36428e93ad7980634156fb5a1c88044984b0c03988951dc7/gmpy2-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f3770aa5e44c5650d18232a0b8b8ed3d12db530d8278d4c800e4de5eef24cac5", size = 708753, upload-time = "2026-02-08T00:56:07.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/74/dac71b2f9f7844c40b38b6e43e3f793193420fd65573258147792cc069ce/gmpy2-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b4cee1fa3647505f53b81dc3b60ac49034768117f6295a04aaf4d3f216b821", size = 1674005, upload-time = "2026-02-08T00:56:10.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/29/16548784d70b2a58919720cb976a968b9b14a1b8ccebfe4a21d21647ecec/gmpy2-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd9f4124d7dc39d50896ba08820049a95f9f3952dcd6e072cc3a9d07361b7f1f", size = 1774200, upload-time = "2026-02-08T00:56:13.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c5/ef9efb075388e91c166f74234cd54897af7a2d3b93c66a9c3a266c796c99/gmpy2-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2f6b38e1b6d2aeb553c936c136c3a12cf983c9f9ce3e211b8632744a15f2bce7", size = 1693346, upload-time = "2026-02-08T00:56:14.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/7e/1a1d6f50bb428434ca6930df0df6d9f8ad914c103106e60574b5df349f36/gmpy2-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:089229ef18b8d804a76fec9bd7e7d653f598a977e8354f7de8850731a48adb37", size = 1731821, upload-time = "2026-02-08T00:56:16.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/47/f1140943bed78da59261edb377b9497b74f6e583d7accc9dc20592753a25/gmpy2-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:f1843f2ca5a1643fac7563a12a6a7d68e539d93de4afe5812355d32fb1613891", size = 1234877, upload-time = "2026-02-08T00:56:17.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/44/a19e4a1628067bf7d27eeda2a1a874b1a5e750e2f5847cc2c49e90946eb5/gmpy2-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:cd5b92fa675dde5151ebe8d89814c78d573e5210cdc162016080782778f15654", size = 855570, upload-time = "2026-02-08T00:56:19.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/f70385e41b265b4f3534c7f41e78eefcf78dfe3a0d490816c697bb0703a9/gmpy2-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f35d6b1a8f067323a0a0d7034699284baebef498b030bbb29ab31d2ec13d1068", size = 857355, upload-time = "2026-02-08T00:56:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/31/637015bd02bc74c6d854fc92ca1c24109a91691df07bc5e10bd14e09fd15/gmpy2-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:392d0560526dfa377c54c5c001d507fbbdea6cf54574895b90a97fc3587fa51e", size = 708996, upload-time = "2026-02-08T00:56:22.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/21/7f8bf79c486cff140aca76d958cdecfd1986cf989d28e14791a6e09004d8/gmpy2-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e900f41cc46700a5f49a4fbdcd5cd895e00bd0c2b9889fb2504ac1d594c21ac2", size = 1667404, upload-time = "2026-02-08T00:56:25.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/1a/6efe94b7eb963362a7023b5c31157de703398d77320273a6dd7492736fff/gmpy2-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:713ba9b7a0a9098591f202e8f24f27ac5dd5001baf088ece1762852608a04b95", size = 1768643, upload-time = "2026-02-08T00:56:27.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/cf/9e9790f55b076d2010e282fc9a80bb4888c54b5e7fe359ae06a1d4bb76ea/gmpy2-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d2ed7b6d557b5d47068e889e2db204321ac855e001316a12928e4e7435f98637", size = 1683858, upload-time = "2026-02-08T00:56:28.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/02/1644480dc9f499f510979033a09069bb5a4fb3e75cf8f79c894d4ba17eed/gmpy2-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d135dcef824e26e1b3af544004d8f98564d090e7cf1001c50cc93d9dc1dc047", size = 1722019, upload-time = "2026-02-08T00:56:29.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/3f/5a74a2c9ac2e6076819649707293e16fd0384bee9f065f097d0f2fb89b0c/gmpy2-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:9dcbb628f9c806f0e6789f2c5e056e67e949b317af0e9ea0c3f0e0488c56e2a8", size = 1236149, upload-time = "2026-02-08T00:56:31.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/34/e9157d26278462feca182515fd58de1e7a2bb5da0ee7ba80aeed0363776c/gmpy2-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:19022e0103aa76803b666720f107d8ab1941c597fd3fe70fadf7c49bac82a097", size = 856534, upload-time = "2026-02-08T00:56:33.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/10/f95d0103be9c1c458d5d92a72cca341a4ce0f1ca3ae6f79839d0f171f7ea/gmpy2-2.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:71dc3734104fa1f300d35ac6f55c7e98f7b0e1c7fd96f27b409110ed1c0c47d2", size = 840903, upload-time = "2026-02-08T00:57:34.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/50/677daeb75c038cdd773d575eefd34e96dbdd7b03c91166e56e6f8ed7acc2/gmpy2-2.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4623e700423396ef3d1658efa83b6feb0615fb68cb0b850e9ac0cba966db34c8", size = 691637, upload-time = "2026-02-08T00:57:35.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/cf/f1eb022f61c7bcc2dc428d345a7c012f0fabe1acb8db0d8216f23a46a915/gmpy2-2.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:692289a37442468856328986e0fab7e7e71c514bc470e1abae82d3bc54ca4cd2", size = 939209, upload-time = "2026-02-08T00:57:37.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ae/c651b8d903f4d8a65e4f959e2fd39c963d36cb2c6bfc452aa6d7db0fc5b3/gmpy2-2.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb379412033b52c3ec6bc44c6eaa134c88a068b6f1f360e6c13ca962082478ee", size = 1039433, upload-time = "2026-02-08T00:57:38.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/1a/72844930f855d50b831a899f53365404ec81c165a68dea6ea3fa1668ba46/gmpy2-2.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d087b262a0356c318a56fbb5c718e4e56762d861b2f9d581adc90a180264db9", size = 1233930, upload-time = "2026-02-08T00:57:40.228Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2555,51 +2570,51 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "grimp"
|
||||
version = "3.13"
|
||||
version = "3.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/b3/ff0d704cdc5cf399d74aabd2bf1694d4c4c3231d4d74b011b8f39f686a86/grimp-3.13.tar.gz", hash = "sha256:759bf6e05186e6473ee71af4119ec181855b2b324f4fcdd78dee9e5b59d87874", size = 847508, upload-time = "2025-10-29T13:04:57.704Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/46/79764cfb61a3ac80dadae5d94fb10acdb7800e31fecf4113cf3d345e4952/grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871", size = 830882, upload-time = "2025-12-10T17:55:01.287Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/cc/d272cf87728a7e6ddb44d3c57c1d3cbe7daf2ffe4dc76e3dc9b953b69ab1/grimp-3.13-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:57745996698932768274a2ed9ba3e5c424f60996c53ecaf1c82b75be9e819ee9", size = 2074518, upload-time = "2025-10-29T13:03:58.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/11/31dc622c5a0d1615b20532af2083f4bba2573aebbba5b9d6911dfd60a37d/grimp-3.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ca29f09710342b94fa6441f4d1102a0e49f0b463b1d91e43223baa949c5e9337", size = 1988182, upload-time = "2025-10-29T13:03:50.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/83/a0e19beb5c42df09e9a60711b227b4f910ba57f46bea258a9e1df883976c/grimp-3.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adda25aa158e11d96dd27166300b955c8ec0c76ce2fd1a13597e9af012aada06", size = 2145832, upload-time = "2025-10-29T13:02:35.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/f5/13752205e290588e970fdc019b4ab2c063ca8da352295c332e34df5d5842/grimp-3.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03e17029d75500a5282b40cb15cdae030bf14df9dfaa6a2b983f08898dfe74b6", size = 2106762, upload-time = "2025-10-29T13:02:51.681Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/30/c4d62543beda4b9a483a6cd5b7dd5e4794aafb511f144d21a452467989a1/grimp-3.13-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6cbfc9d2d0ebc0631fb4012a002f3d8f4e3acb8325be34db525c0392674433b8", size = 2256674, upload-time = "2025-10-29T13:03:27.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/ea/d07ed41b7121719c3f7bf30c9881dbde69efeacfc2daf4e4a628efe5f123/grimp-3.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:161449751a085484608c5b9f863e41e8fb2a98e93f7312ead5d831e487a94518", size = 2442699, upload-time = "2025-10-29T13:03:04.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/a0/1923f0480756effb53c7e6cef02a3918bb519a86715992720838d44f0329/grimp-3.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:119628fbe7f941d1e784edac98e8ced7e78a0b966a4ff2c449e436ee860bd507", size = 2317145, upload-time = "2025-10-29T13:03:15.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/d9/aef4c8350090653e34bc755a5d9e39cc300f5c46c651c1d50195f69bf9ab/grimp-3.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ca1ac776baf1fa105342b23c72f2e7fdd6771d4cce8d2903d28f92fd34a9e8f", size = 2180288, upload-time = "2025-10-29T13:03:41.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2e/a206f76eccffa56310a1c5d5950ed34923a34ae360cb38e297604a288837/grimp-3.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:941ff414cc66458f56e6af93c618266091ea70bfdabe7a84039be31d937051ee", size = 2328696, upload-time = "2025-10-29T13:04:06.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/3b/88ff1554409b58faf2673854770e6fc6e90167a182f5166147b7618767d7/grimp-3.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:87ad9bcd1caaa2f77c369d61a04b9f2f1b87f4c3b23ae6891b2c943193c4ec62", size = 2367574, upload-time = "2025-10-29T13:04:21.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/b3/e9c99ecd94567465a0926ae7136e589aed336f6979a4cddcb8dfba16d27c/grimp-3.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:751fe37104a4f023d5c6556558b723d843d44361245c20f51a5d196de00e4774", size = 2358842, upload-time = "2025-10-29T13:04:34.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/65/a5fffeeb9273e06dfbe962c8096331ba181ca8415c5f9d110b347f2c0c34/grimp-3.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9b561f79ec0b3a4156937709737191ad57520f2d58fa1fc43cd79f67839a3cd7", size = 2382268, upload-time = "2025-10-29T13:04:46.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/79/2f3b4323184329b26b46de2b6d1bd64ba1c26e0a9c3cfa0aaecec237b75e/grimp-3.13-cp311-cp311-win32.whl", hash = "sha256:52405ea8c8f20cf5d2d1866c80ee3f0243a38af82bd49d1464c5e254bf2e1f8f", size = 1759345, upload-time = "2025-10-29T13:05:10.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ce/e86cf73e412a6bf531cbfa5c733f8ca48b28ebea23a037338be763f24849/grimp-3.13-cp311-cp311-win_amd64.whl", hash = "sha256:6a45d1d3beeefad69717b3718e53680fb3579fe67696b86349d6f39b75e850bf", size = 1859382, upload-time = "2025-10-29T13:05:01.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/06/ff7e3d72839f46f0fccdc79e1afe332318986751e20f65d7211a5e51366c/grimp-3.13-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3e715c56ffdd055e5c84d27b4c02d83369b733e6a24579d42bbbc284bd0664a9", size = 2070161, upload-time = "2025-10-29T13:03:59.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/2f/a95bdf8996db9400fd7e288f32628b2177b8840fe5f6b7cd96247b5fa173/grimp-3.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f794dea35a4728b948ab8fec970ffbdf2589b34209f3ab902cf8a9148cf1eaad", size = 1984365, upload-time = "2025-10-29T13:03:51.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/45/cc3d7f3b7b4d93e0b9d747dc45ed73a96203ba083dc857f24159eb6966b4/grimp-3.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69571270f2c27e8a64b968195aa7ecc126797112a9bf1e804ff39ba9f42d6f6d", size = 2145486, upload-time = "2025-10-29T13:02:36.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/92/a6e493b71cb5a9145ad414cc4790c3779853372b840a320f052b22879606/grimp-3.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f7b226398ae476762ef0afb5ef8f838d39c8e0e2f6d1a4378ce47059b221a4a", size = 2106747, upload-time = "2025-10-29T13:02:53.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/8d/36a09f39fe14ad8843ef3ff81090ef23abbd02984c1fcc1cef30e5713d82/grimp-3.13-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5498aeac4df0131a1787fcbe9bb460b52fc9b781ec6bba607fd6a7d6d3ea6fce", size = 2257027, upload-time = "2025-10-29T13:03:29.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/7a/90f78787f80504caeef501f1bff47e8b9f6058d45995f1d4c921df17bfef/grimp-3.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4be702bb2b5c001a6baf709c452358470881e15e3e074cfc5308903603485dcb", size = 2441208, upload-time = "2025-10-29T13:03:05.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/0fbd3a3e914512b9602fa24c8ebc85a8925b101f04f8a8c1d1e220e0a717/grimp-3.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fcf988f3e3d272a88f7be68f0c1d3719fee8624d902e9c0346b9015a0ea6a65", size = 2318758, upload-time = "2025-10-29T13:03:17.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/e9/29c685e88b3b0688f0a2e30c0825e02076ecdf22bc0e37b1468562eaa09a/grimp-3.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ede36d104ff88c208140f978de3345f439345f35b8ef2b4390c59ef6984deba", size = 2180523, upload-time = "2025-10-29T13:03:42.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bc/7cc09574b287b8850a45051e73272f365259d9b6ca58d7b8773265c6fe35/grimp-3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b35e44bb8dc80e0bd909a64387f722395453593a1884caca9dc0748efea33764", size = 2328855, upload-time = "2025-10-29T13:04:08.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/86/3b0845900c8f984a57c6afe3409b20638065462d48b6afec0fd409fd6118/grimp-3.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:becb88e9405fc40896acd6e2b9bbf6f242a5ae2fd43a1ec0a32319ab6c10a227", size = 2367756, upload-time = "2025-10-29T13:04:22.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/2d/4e70e8c06542db92c3fffaecb43ebfc4114a411505bff574d4da7d82c7db/grimp-3.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a66585b4af46c3fbadbef495483514bee037e8c3075ed179ba4f13e494eb7899", size = 2358595, upload-time = "2025-10-29T13:04:35.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/06/c511d39eb6c73069af277f4e74991f1f29a05d90cab61f5416b9fc43932f/grimp-3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29f68c6e2ff70d782ca0e989ec4ec44df73ba847937bcbb6191499224a2f84e2", size = 2381464, upload-time = "2025-10-29T13:04:48.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f5/42197d69e4c9e2e7eed091d06493da3824e07c37324155569aa895c3b5f7/grimp-3.13-cp312-cp312-win32.whl", hash = "sha256:cc996dcd1a44ae52d257b9a3e98838f8ecfdc42f7c62c8c82c2fcd3828155c98", size = 1758510, upload-time = "2025-10-29T13:05:11.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/dd/59c5f19f51e25f3dbf1c9e88067a88165f649ba1b8e4174dbaf1c950f78b/grimp-3.13-cp312-cp312-win_amd64.whl", hash = "sha256:e2966435947e45b11568f04a65863dcf836343c11ae44aeefdaa7f07eb1a0576", size = 1859530, upload-time = "2025-10-29T13:05:02.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/81/82de1b5d82701214b1f8e32b2e71fde8e1edbb4f2cdca9beb22ee6c8796d/grimp-3.13-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a6a3c76525b018c85c0e3a632d94d72be02225f8ada56670f3f213cf0762be4", size = 2145955, upload-time = "2025-10-29T13:02:47.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/ae/ada18cb73bdf97094af1c60070a5b85549482a57c509ee9a23fdceed4fc3/grimp-3.13-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239e9b347af4da4cf69465bfa7b2901127f6057bc73416ba8187fb1eabafc6ea", size = 2107150, upload-time = "2025-10-29T13:02:59.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/5e/6d8c65643ad5a1b6e00cc2cd8f56fc063923485f07c59a756fa61eefe7f2/grimp-3.13-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6db85ce2dc2f804a2edd1c1e9eaa46d282e1f0051752a83ca08ca8b87f87376", size = 2257515, upload-time = "2025-10-29T13:03:36.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/62/72cbfd7d0f2b95a53edd01d5f6b0d02bde38db739a727e35b76c13e0d0a8/grimp-3.13-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e000f3590bcc6ff7c781ebbc1ac4eb919f97180f13cc4002c868822167bd9aed", size = 2441262, upload-time = "2025-10-29T13:03:12.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/00/b9209ab385567c3bddffb5d9eeecf9cb432b05c30ca8f35904b06e206a89/grimp-3.13-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e2374c217c862c1af933a430192d6a7c6723ed1d90303f1abbc26f709bbb9263", size = 2318557, upload-time = "2025-10-29T13:03:23.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/4d/a3d73c11d09da00a53ceafe2884a71c78f5a76186af6d633cadd6c85d850/grimp-3.13-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ed0ff17d559ff2e7fa1be8ae086bc4fedcace5d7b12017f60164db8d9a8d806", size = 2180811, upload-time = "2025-10-29T13:03:47.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/9a/1cdfaa7d7beefd8859b190dfeba11d5ec074e8702b2903e9f182d662ed63/grimp-3.13-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:43960234aabce018c8d796ec8b77c484a1c9cbb6a3bc036a0d307c8dade9874c", size = 2329205, upload-time = "2025-10-29T13:04:15.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/73/b36f86ef98df96e7e8a6166dfa60c8db5d597f051e613a3112f39a870b4c/grimp-3.13-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:44420b638b3e303f32314bd4d309f15de1666629035acd1cdd3720c15917ac85", size = 2368745, upload-time = "2025-10-29T13:04:29.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/2f/0ce37872fad5c4b82d727f6e435fd5bc76f701279bddc9666710318940cf/grimp-3.13-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:f6127fdb982cf135612504d34aa16b841f421e54751fcd54f80b9531decb2b3f", size = 2358753, upload-time = "2025-10-29T13:04:42.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/23/935c888ac9ee71184fe5adf5ea86648746739be23c85932857ac19fc1d17/grimp-3.13-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:69893a9ef1edea25226ed17e8e8981e32900c59703972e0780c0e927ce624f75", size = 2383066, upload-time = "2025-10-29T13:04:55.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/31/d4a86207c38954b6c3d859a1fc740a80b04bbe6e3b8a39f4e66f9633dfa4/grimp-3.14-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f1c91e3fa48c2196bf62e3c71492140d227b2bfcd6d15e735cbc0b3e2d5308e0", size = 2185572, upload-time = "2025-12-10T17:53:41.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/61/ed4cba5bd75d37fe46e17a602f616619a9e4f74ad8adfcf560ce4b2a1697/grimp-3.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6291c8f1690a9fe21b70923c60b075f4a89676541999e3d33084cbc69ac06a1", size = 2118002, upload-time = "2025-12-10T17:53:18.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/6a/688f6144d0b207d7845bd8ab403820a83630ce3c9420cbbc7c9e9282f9c0/grimp-3.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ec312383935c2d09e4085c8435780ada2e13ebef14e105609c2988a02a5b2ce", size = 2283939, upload-time = "2025-12-10T17:52:06.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/98/4c540de151bf3fd58d6d7b3fe2269b6a6af6c61c915de1bc991802bfaff8/grimp-3.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f43cbf640e73ee703ad91639591046828d20103a1c363a02516e77a66a4ac07", size = 2233693, upload-time = "2025-12-10T17:52:18.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/7b/84b4b52b6c6dd5bf083cb1a72945748f56ea2e61768bbebf87e8d9d0ef75/grimp-3.14-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a93c9fddccb9ff16f5c6b5fca44227f5f86cba7cffc145d2176119603d2d7c7", size = 2389745, upload-time = "2025-12-10T17:53:00.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/33/31b96907c7dd78953df5e1ce67c558bd6057220fa1203d28d52566315a2e/grimp-3.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5653a2769fdc062cb7598d12200352069c9c6559b6643af6ada3639edb98fcc3", size = 2569055, upload-time = "2025-12-10T17:52:33.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/24/ce1a8110f3d5b178153b903aafe54b6a9216588b5bff3656e30af43e9c29/grimp-3.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:071c7ddf5e5bb7b2fdf79aefdf6e1c237cd81c095d6d0a19620e777e85bf103c", size = 2358044, upload-time = "2025-12-10T17:52:47.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7f/16d98c02287bc99884843478b9a68b04a2ef13b5cb8b9f36a9ca7daea75b/grimp-3.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e01b7a4419f535b667dfdcb556d3815b52981474f791fb40d72607228389a31", size = 2310304, upload-time = "2025-12-10T17:53:09.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/8c/0fde9781b0f6b4f9227d485685f48f6bcc70b95af22e2f85ff7f416cbfc1/grimp-3.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c29682f336151d1d018d0c3aa9eeaa35734b970e4593fa396b901edca7ef5c79", size = 2463682, upload-time = "2025-12-10T17:53:49.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/cb/2baff301c2c2cc2792b6e225ea0784793ca587c81b97572be0bad122cfc8/grimp-3.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a5c4fd71f363ea39e8aab0630010ced77a8de9789f27c0acdd0d7e6269d4a8ef", size = 2500573, upload-time = "2025-12-10T17:54:03.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/69/797e4242f42d6665da5fe22cb250cae3f14ece4cb22ad153e9cd97158179/grimp-3.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766911e3ba0b13d833fdd03ad1f217523a8a2b2527b5507335f71dca1153183d", size = 2503005, upload-time = "2025-12-10T17:54:32.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/45/da1a27a6377807ca427cd56534231f0920e1895e16630204f382a0df14c5/grimp-3.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:154e84a2053e9f858ae48743de23a5ad4eb994007518c29371276f59b8419036", size = 2515776, upload-time = "2025-12-10T17:54:47.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/8d/b918a29ce98029cd7a9e33a584be43a93288d5283fb7ccef5b6b2ba39ede/grimp-3.14-cp311-cp311-win32.whl", hash = "sha256:3189c86c3e73016a1907ee3ba9f7a6ca037e3601ad09e60ce9bf12b88877f812", size = 1873189, upload-time = "2025-12-10T17:55:11.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/d7/2327c203f83a25766fbd62b0df3b24230d422b6e53518ff4d1c5e69793f1/grimp-3.14-cp311-cp311-win_amd64.whl", hash = "sha256:201f46a6a4e5ee9dfba4a2f7d043f7deab080d1d84233f4a1aee812678c25307", size = 2014277, upload-time = "2025-12-10T17:55:04.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/d6/a35ff62f35aa5fd148053506eddd7a8f2f6afaed31870dc608dd0eb38e4f/grimp-3.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ffabc6940301214753bad89ec0bfe275892fa1f64b999e9a101f6cebfc777133", size = 2178573, upload-time = "2025-12-10T17:53:42.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/bd2e80273da4d46110969fc62252e5372e0249feb872bc7fe76fdc7f1818/grimp-3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:075d9a1c78d607792d0ed8d4d3d7754a621ef04c8a95eaebf634930dc9232bb2", size = 2110452, upload-time = "2025-12-10T17:53:19.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/c3/7307249c657d34dca9d250d73ba027d6cfe15a98fb3119b6e5210bc388b7/grimp-3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ff52addeb20955a4d6aa097bee910573ffc9ef0d3c8a860844f267ad958156", size = 2283064, upload-time = "2025-12-10T17:52:07.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/d2/cae4cf32dc8d4188837cc4ab183300d655f898969b0f169e240f3b7c25be/grimp-3.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10e0663e961fcbe8d0f54608854af31f911f164c96a44112d5173050132701f", size = 2235893, upload-time = "2025-12-10T17:52:20.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/92/3f58bc3064fc305dac107d08003ba65713a5bc89a6d327f1c06b30cce752/grimp-3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ab874d7ddddc7a1291259cf7c31a4e7b5c612e9da2e24c67c0eb1a44a624e67", size = 2393376, upload-time = "2025-12-10T17:53:02.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b8/f476f30edf114f04cb58e8ae162cb4daf52bda0ab01919f3b5b7edb98430/grimp-3.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54fec672ec83355636a852177f5a470c964bede0f6730f9ba3c7b5c8419c9eab", size = 2571342, upload-time = "2025-12-10T17:52:35.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/ae/2e44d3c4f591f95f86322a8f4dbb5aac17001d49e079f3a80e07e7caaf09/grimp-3.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9e221b5e8070a916c780e88c877fee2a61c95a76a76a2a076396e459511b0bb", size = 2359022, upload-time = "2025-12-10T17:52:49.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/ac/42b4d6bc0ea119ce2e91e1788feabf32c5433e9617dbb495c2a3d0dc7f12/grimp-3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea6b495f9b4a8d82f5ce544921e76d0d12017f5d1ac3a3bd2f5ac88ab055b1c", size = 2309424, upload-time = "2025-12-10T17:53:11.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/c7/6a731989625c1790f4da7602dcbf9d6525512264e853cda77b3b3602d5e0/grimp-3.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:655e8d3f79cd99bb859e09c9dd633515150e9d850879ca71417d5ac31809b745", size = 2462754, upload-time = "2025-12-10T17:53:50.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/4d/3d1571c0a39a59dd68be4835f766da64fe64cbab0d69426210b716a8bdf0/grimp-3.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a14f10b1b71c6c37647a76e6a49c226509648107abc0f48c1e3ecd158ba05531", size = 2501356, upload-time = "2025-12-10T17:54:06.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/d1/8950b8229095ebda5c54c8784e4d1f0a6e19423f2847289ef9751f878798/grimp-3.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:81685111ee24d3e25f8ed9e77ed00b92b58b2414e1a1c2937236026900972744", size = 2504631, upload-time = "2025-12-10T17:54:34.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/e6/23bed3da9206138d36d01890b656c7fb7adfb3a37daac8842d84d8777ade/grimp-3.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce8352a8ea0e27b143136ea086582fc6653419aa8a7c15e28ed08c898c42b185", size = 2514751, upload-time = "2025-12-10T17:54:49.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/45/6f1f55c97ee982f133ec5ccb22fc99bf5335aee70c208f4fb86cd833b8d5/grimp-3.14-cp312-cp312-win32.whl", hash = "sha256:3fc0f98b3c60d88e9ffa08faff3200f36604930972f8b29155f323b76ea25a06", size = 1875041, upload-time = "2025-12-10T17:55:13.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/cf/03ba01288e2a41a948bc8526f32c2eeaddd683ed34be1b895e31658d5a4c/grimp-3.14-cp312-cp312-win_amd64.whl", hash = "sha256:6bca77d1d50c8dc402c96af21f4e28e2f1e9938eeabd7417592a22bd83cde3c3", size = 2013868, upload-time = "2025-12-10T17:55:05.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/cc/dbc00210d0324b8fc1242d8e857757c7e0b62ff0fc0c1bc8dcc42342da85/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c8a8aab9b4310a7e69d7d845cac21cf14563aa0520ea322b948eadeae56d303", size = 2284804, upload-time = "2025-12-10T17:52:16.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/89/851d3d345342e9bcec3fe85d3997db29501fa59f958c1566bf3e24d9d7d9/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d781943b27e5875a41c8f9cfc80f8f0a349f864379192b8c3faa0e6a22593313", size = 2235176, upload-time = "2025-12-10T17:52:30.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/78/5f94702a8d5c121cafcdc9664de34c34f19d0d91a1127bf3946a2631f7a3/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9630d4633607aff94d0ac84b9c64fef1382cdb05b00d9acbde47f8745e264871", size = 2391258, upload-time = "2025-12-10T17:53:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a2/df8c79de5c9e227856d048cc1551c4742a5f97660c40304ac278bd48607f/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cb00e1bcca583668554a8e9e1e4229a1d11b0620969310aae40148829ff6a32", size = 2571443, upload-time = "2025-12-10T17:52:43.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/21/747b7ed9572bbdc34a76dfec12ce510e80164b1aa06d3b21b34994e5f567/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3389da4ceaaa7f7de24a668c0afc307a9f95997bd90f81ec359a828a9bd1d270", size = 2357767, upload-time = "2025-12-10T17:52:57.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/e6/485c5e3b64933e71f72f0cc45b0d7130418a6a5a13cedc2e8411bd76f290/grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd7a32970ef97e42d4e7369397c7795287d84a736d788ccb90b6c14f0561d975", size = 2309069, upload-time = "2025-12-10T17:53:15.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/bd/12024a8cba1c77facc1422a7b48cd0d04c252fc9178fd6f99dc05a8af57b/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:fd1278623fa09f62abc0fd8a6500f31b421a1fd479980f44c2926020a0becf02", size = 2466429, upload-time = "2025-12-10T17:54:00.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7f/0e5977887e1c8f00f84bb4125217534806ffdcef9cf52f3580aa3b151f4b/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:9cfa52c89333d3d8fe9dc782529e888270d060231c3783e036d424044671dde0", size = 2501190, upload-time = "2025-12-10T17:54:30.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/6b/06acb94b6d0d8c7277bb3e33f93224aa3be5b04643f853479d3bf7b23ace/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:48a5be4a12fca6587e6885b4fc13b9e242ab8bf874519292f0f13814aecf52cc", size = 2503440, upload-time = "2025-12-10T17:54:44.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4d/2e531370d12e7a564f67f680234710bbc08554238a54991cd244feb61fb6/grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3fcc332466783a12a42cd317fd344c30fe734ba4fa2362efff132dc3f8d36da7", size = 2516525, upload-time = "2025-12-10T17:54:58.987Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2938,17 +2953,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "import-linter"
|
||||
version = "2.7"
|
||||
version = "2.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "grimp" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/20/cc371a35123cd6afe4c8304cf199a53530a05f7437eda79ce84d9c6f6949/import_linter-2.7.tar.gz", hash = "sha256:7bea754fac9cde54182c81eeb48f649eea20b865219c39f7ac2abd23775d07d2", size = 219914, upload-time = "2025-11-19T11:44:28.193Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/c4/a83cc1ea9ed0171725c0e2edc11fd929994d4f026028657e8b30d62bca37/import_linter-2.10.tar.gz", hash = "sha256:c6a5057d2dbd32e1854c4d6b60e90dfad459b7ab5356230486d8521f25872963", size = 1149263, upload-time = "2026-02-06T17:57:24.779Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b5/26a1d198f3de0676354a628f6e2a65334b744855d77e25eea739287eea9a/import_linter-2.7-py3-none-any.whl", hash = "sha256:be03bbd467b3f0b4535fb3ee12e07995d9837864b307df2e78888364e0ba012d", size = 46197, upload-time = "2025-11-19T11:44:27.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/e5/4b7b9435eac78ecfd537fa1004a0bcf0f4eac17d3a893f64d38a7bacb51b/import_linter-2.10-py3-none-any.whl", hash = "sha256:cc2ddd7ec0145cbf83f3b25391d2a5dbbf138382aaf80708612497fa6ebc8f60", size = 637081, upload-time = "2026-02-06T17:57:23.386Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3921,20 +3938,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opensearch-protobufs"
|
||||
version = "0.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/e2/8a09dbdbfe51e30dfecb625a0f5c524a53bfa4b1fba168f73ac85621dba2/opensearch_protobufs-0.19.0-py3-none-any.whl", hash = "sha256:5137c9c2323cc7debb694754b820ca4cfb5fc8eb180c41ff125698c3ee11bfc2", size = 39778, upload-time = "2025-09-29T20:05:52.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opensearch-py"
|
||||
version = "2.4.0"
|
||||
version = "3.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "events" },
|
||||
{ name = "opensearch-protobufs" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "six" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/dc/acb182db6bb0c71f1e6e41c49260e01d68e52a03efb64e44aed3cc7f483f/opensearch-py-2.4.0.tar.gz", hash = "sha256:7eba2b6ed2ddcf33225bfebfba2aee026877838cc39f760ec80f27827308cc4b", size = 182924, upload-time = "2023-11-15T21:41:37.329Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/9f/d4969f7e8fa221bfebf254cc3056e7c743ce36ac9874e06110474f7c947d/opensearch_py-3.1.0.tar.gz", hash = "sha256:883573af13175ff102b61c80b77934a9e937bdcc40cda2b92051ad53336bc055", size = 258616, upload-time = "2025-11-20T16:37:36.777Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/98/178aacf07ece7f95d1948352778702898d57c286053813deb20ebb409923/opensearch_py-2.4.0-py2.py3-none-any.whl", hash = "sha256:316077235437c8ceac970232261f3393c65fb92a80f33c5b106f50f1dab24fd9", size = 258405, upload-time = "2023-11-15T21:41:35.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a1/293c8ad81768ad625283d960685bde07c6302abf20a685e693b48ab6eb91/opensearch_py-3.1.0-py3-none-any.whl", hash = "sha256:e5af83d0454323e6ea9ddee8c0dcc185c0181054592d23cb701da46271a3b65b", size = 385729, upload-time = "2025-11-20T16:37:34.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4824,7 +4854,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.10"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -4832,57 +4862,64 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/54/ecab642b3bed45f7d5f59b38443dcb36ef50f85af192e6ece103dbfe9587/pydantic-2.11.10.tar.gz", hash = "sha256:dc280f0982fbda6c38fada4e476dc0a4f3aeaf9c6ad4c28df68a666ec3c61423", size = 788494, upload-time = "2025-10-04T10:40:41.338Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/1f/73c53fcbfb0b5a78f91176df41945ca466e71e9d9d836e5c522abda39ee7/pydantic-2.11.10-py3-none-any.whl", hash = "sha256:802a655709d49bd004c31e865ef37da30b540786a46bfce02333e0e24b5fe29a", size = 444823, upload-time = "2025-10-04T10:40:39.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
version = "2.41.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5013,11 +5050,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.6.2"
|
||||
version = "6.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/bb/a44bab1ac3c54dbcf653d7b8bcdee93dddb2d3bf025a3912cacb8149a2f2/pypdf-6.6.2.tar.gz", hash = "sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016", size = 5281850, upload-time = "2026-01-26T11:57:55.964Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/63/3437c4363483f2a04000a48f1cd48c40097f69d580363712fa8b0b4afe45/pypdf-6.7.1.tar.gz", hash = "sha256:6b7a63be5563a0a35d54c6d6b550d75c00b8ccf36384be96365355e296e6b3b0", size = 5302208, upload-time = "2026-02-17T17:00:48.88Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/be/549aaf1dfa4ab4aed29b09703d2fb02c4366fc1f05e880948c296c5764b9/pypdf-6.6.2-py3-none-any.whl", hash = "sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba", size = 329132, upload-time = "2026-01-26T11:57:54.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/77/38bd7744bb9e06d465b0c23879e6d2c187d93a383f8fa485c862822bb8a3/pypdf-6.7.1-py3-none-any.whl", hash = "sha256:a02ccbb06463f7c334ce1612e91b3e68a8e827f3cee100b9941771e6066b094e", size = 331048, upload-time = "2026-02-17T17:00:46.991Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5221,15 +5258,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-docx"
|
||||
version = "1.1.2"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/35/e4/386c514c53684772885009c12b67a7edd526c15157778ac1b138bc75063e/python_docx-1.1.2.tar.gz", hash = "sha256:0cf1f22e95b9002addca7948e16f2cd7acdfd498047f1941ca5d293db7762efd", size = 5656581, upload-time = "2024-05-01T19:41:57.772Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/f7/eddfe33871520adab45aaa1a71f0402a2252050c14c7e3009446c8f4701c/python_docx-1.2.0.tar.gz", hash = "sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce", size = 5723256, upload-time = "2025-06-16T20:46:27.921Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/3d/330d9efbdb816d3f60bf2ad92f05e1708e4a1b9abe80461ac3444c83f749/python_docx-1.1.2-py3-none-any.whl", hash = "sha256:08c20d6058916fb19853fcf080f7f42b6270d89eac9fa5f8c15f691c0017fabe", size = 244315, upload-time = "2024-05-01T19:41:47.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5439,14 +5476,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "6.1.1"
|
||||
version = "7.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11.3'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/8b/14ef373ffe71c0d2fde93c204eab78472ea13c021d9aee63b0e11bd65896/redis-6.1.1.tar.gz", hash = "sha256:88c689325b5b41cedcbdbdfd4d937ea86cf6dab2222a83e86d8a466e4b3d2600", size = 4629515, upload-time = "2025-06-02T11:44:04.137Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cd/29503c609186104c363ef1f38d6e752e7d91ef387fc90aa165e96d69f446/redis-6.1.1-py3-none-any.whl", hash = "sha256:ed44d53d065bbe04ac6d76864e331cfe5c5353f86f6deccc095f8794fd15bb2e", size = 273930, upload-time = "2025-06-02T11:44:02.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -6443,11 +6480,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "types-markdown"
|
||||
version = "3.7.0.20250322"
|
||||
version = "3.10.2.20260211"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/fd/b4bd01b8c46f021c35a07aa31fe1dc45d21adc9fc8d53064bfa577aae73d/types_markdown-3.7.0.20250322.tar.gz", hash = "sha256:a48ed82dfcb6954592a10f104689d2d44df9125ce51b3cee20e0198a5216d55c", size = 18052, upload-time = "2025-03-22T02:48:46.193Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/2e/35b30a09f6ee8a69142408d3ceb248c4454aa638c0a414d8704a3ef79563/types_markdown-3.10.2.20260211.tar.gz", hash = "sha256:66164310f88c11a58c6c706094c6f8c537c418e3525d33b76276a5fbd66b01ce", size = 19768, upload-time = "2026-02-11T04:19:29.497Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/59/ee46617bc2b5e43bc06a000fdcd6358a013957e30ad545bed5e3456a4341/types_markdown-3.7.0.20250322-py3-none-any.whl", hash = "sha256:7e855503027b4290355a310fb834871940d9713da7c111f3e98a5e1cbc77acfb", size = 23699, upload-time = "2025-03-22T02:48:45.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/c9/659fa2df04b232b0bfcd05d2418e683080e91ec68f636f3c0a5a267350e7/types_markdown-3.10.2.20260211-py3-none-any.whl", hash = "sha256:2d94d08587e3738203b3c4479c449845112b171abe8b5cadc9b0c12fcf3e99da", size = 25854, upload-time = "2026-02-11T04:19:28.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7177,14 +7214,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.5"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -588,7 +588,7 @@ export default translation
|
||||
const trimmedKeyLine = keyLine.trim()
|
||||
|
||||
// If key line ends with ":" (not complete value), it's likely multiline
|
||||
if (trimmedKeyLine.endsWith(':') && !trimmedKeyLine.includes('{') && !trimmedKeyLine.match(/:\s*['"`]/)) {
|
||||
if (trimmedKeyLine.endsWith(':') && !trimmedKeyLine.includes('{') && !/:\s*['"`]/.exec(trimmedKeyLine)) {
|
||||
// Find the value lines that belong to this key
|
||||
let currentLine = targetLineIndex + 1
|
||||
let foundValue = false
|
||||
@@ -604,7 +604,7 @@ export default translation
|
||||
}
|
||||
|
||||
// Check if this line starts a new key (indicates end of current value)
|
||||
if (trimmed.match(/^\w+\s*:/))
|
||||
if (/^\w+\s*:/.exec(trimmed))
|
||||
break
|
||||
|
||||
// Check if this line is part of the value
|
||||
|
||||
@@ -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>,
|
||||
}))
|
||||
|
||||
@@ -94,7 +94,7 @@ const ConfigPopup: FC<PopupProps> = ({
|
||||
const switchContent = (
|
||||
<Switch
|
||||
className="ml-3"
|
||||
defaultValue={enabled}
|
||||
value={enabled}
|
||||
onChange={onStatusChange}
|
||||
disabled={providerAllNotConfigured}
|
||||
/>
|
||||
|
||||
@@ -144,7 +144,7 @@ const Annotation: FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="system-sm-regular text-text-tertiary">{t('description', { ns: 'appLog' })}</p>
|
||||
<p className="text-text-tertiary system-sm-regular">{t('description', { ns: 'appLog' })}</p>
|
||||
<div className="relative flex h-full flex-1 flex-col py-4">
|
||||
<Filter appId={appDetail.id} queryParams={queryParams} setQueryParams={setQueryParams}>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -152,10 +152,10 @@ const Annotation: FC<Props> = (props) => {
|
||||
<>
|
||||
<div className={cn(!annotationConfig?.enabled && 'pr-2', 'flex h-7 items-center space-x-1 rounded-lg border border-components-panel-border bg-components-panel-bg-blur pl-2')}>
|
||||
<MessageFast className="h-4 w-4 text-util-colors-indigo-indigo-600" />
|
||||
<div className="system-sm-medium text-text-primary">{t('name', { ns: 'appAnnotation' })}</div>
|
||||
<div className="text-text-primary system-sm-medium">{t('name', { ns: 'appAnnotation' })}</div>
|
||||
<Switch
|
||||
key={controlRefreshSwitch}
|
||||
defaultValue={annotationConfig?.enabled}
|
||||
value={annotationConfig?.enabled ?? false}
|
||||
size="md"
|
||||
onChange={async (value) => {
|
||||
if (value) {
|
||||
|
||||
@@ -121,7 +121,7 @@ const ConfigVision: FC = () => {
|
||||
<ParamConfig />
|
||||
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
|
||||
<Switch
|
||||
defaultValue={isImageEnabled}
|
||||
value={isImageEnabled}
|
||||
onChange={handleChange}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
@@ -298,7 +298,7 @@ const AgentTools: FC = () => {
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
value={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted || readonly}
|
||||
size="md"
|
||||
onChange={(enabled) => {
|
||||
|
||||
@@ -69,7 +69,7 @@ const ConfigAudio: FC = () => {
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
|
||||
<Switch
|
||||
defaultValue={isAudioEnabled}
|
||||
value={isAudioEnabled}
|
||||
onChange={handleChange}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
@@ -69,7 +69,7 @@ const ConfigDocument: FC = () => {
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
|
||||
<Switch
|
||||
defaultValue={isDocumentEnabled}
|
||||
value={isDocumentEnabled}
|
||||
onChange={handleChange}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
@@ -188,14 +188,14 @@ const ConfigContent: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="system-xl-semibold text-text-primary">{t('retrievalSettings', { ns: 'dataset' })}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
<div className="text-text-primary system-xl-semibold">{t('retrievalSettings', { ns: 'dataset' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">
|
||||
{t('defaultRetrievalTip', { ns: 'dataset' })}
|
||||
</div>
|
||||
{type === RETRIEVE_TYPE.multiWay && (
|
||||
<>
|
||||
<div className="my-2 flex flex-col items-center py-1">
|
||||
<div className="system-xs-semibold-uppercase mb-2 mr-2 shrink-0 text-text-secondary">
|
||||
<div className="mb-2 mr-2 shrink-0 text-text-secondary system-xs-semibold-uppercase">
|
||||
{t('rerankSettings', { ns: 'dataset' })}
|
||||
</div>
|
||||
<Divider bgStyle="gradient" className="m-0 !h-px" />
|
||||
@@ -203,21 +203,21 @@ const ConfigContent: FC<Props> = ({
|
||||
{
|
||||
selectedDatasetsMode.inconsistentEmbeddingModel
|
||||
&& (
|
||||
<div className="system-xs-medium mt-4 text-text-warning">
|
||||
<div className="mt-4 text-text-warning system-xs-medium">
|
||||
{t('inconsistentEmbeddingModelTip', { ns: 'dataset' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedDatasetsMode.mixtureInternalAndExternal && (
|
||||
<div className="system-xs-medium mt-4 text-text-warning">
|
||||
<div className="mt-4 text-text-warning system-xs-medium">
|
||||
{t('mixtureInternalAndExternalTip', { ns: 'dataset' })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
selectedDatasetsMode.allExternal && (
|
||||
<div className="system-xs-medium mt-4 text-text-warning">
|
||||
<div className="mt-4 text-text-warning system-xs-medium">
|
||||
{t('allExternalTip', { ns: 'dataset' })}
|
||||
</div>
|
||||
)
|
||||
@@ -225,7 +225,7 @@ const ConfigContent: FC<Props> = ({
|
||||
{
|
||||
selectedDatasetsMode.mixtureHighQualityAndEconomic
|
||||
&& (
|
||||
<div className="system-xs-medium mt-4 text-text-warning">
|
||||
<div className="mt-4 text-text-warning system-xs-medium">
|
||||
{t('mixtureHighQualityAndEconomicTip', { ns: 'dataset' })}
|
||||
</div>
|
||||
)
|
||||
@@ -238,7 +238,7 @@ const ConfigContent: FC<Props> = ({
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
|
||||
'flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary system-sm-medium',
|
||||
selectedRerankMode === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
|
||||
)}
|
||||
onClick={() => handleRerankModeChange(option.value)}
|
||||
@@ -267,12 +267,12 @@ const ConfigContent: FC<Props> = ({
|
||||
canManuallyToggleRerank && (
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={showRerankModel}
|
||||
value={showRerankModel ?? false}
|
||||
onChange={handleManuallyToggleRerank}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<div className="system-sm-semibold ml-1 leading-[32px] text-text-secondary">{t('modelProvider.rerankModel.key', { ns: 'common' })}</div>
|
||||
<div className="ml-1 leading-[32px] text-text-secondary system-sm-semibold">{t('modelProvider.rerankModel.key', { ns: 'common' })}</div>
|
||||
<Tooltip
|
||||
popupContent={(
|
||||
<div className="w-[200px]">
|
||||
|
||||
@@ -109,7 +109,7 @@ const Configuration: FC = () => {
|
||||
const [hasFetchedDetail, setHasFetchedDetail] = useState(false)
|
||||
const isLoading = !hasFetchedDetail
|
||||
const pathname = usePathname()
|
||||
const matched = pathname.match(/\/app\/([^/]+)/)
|
||||
const matched = /\/app\/([^/]+)/.exec(pathname)
|
||||
const appId = (matched?.length && matched[1]) ? matched[1] : ''
|
||||
const [mode, setMode] = useState<AppModeEnum>(AppModeEnum.CHAT)
|
||||
const [publishedConfig, setPublishedConfig] = useState<PublishConfig | null>(null)
|
||||
|
||||
@@ -130,7 +130,7 @@ const Tools = () => {
|
||||
className="flex h-7 cursor-pointer items-center px-3 text-xs font-medium text-gray-700"
|
||||
onClick={() => handleOpenExternalDataToolModal({}, -1)}
|
||||
>
|
||||
<RiAddLine className="mr-[5px] h-3.5 w-3.5 " />
|
||||
<RiAddLine className="mr-[5px] h-3.5 w-3.5" />
|
||||
{t('operation.add', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@ const Tools = () => {
|
||||
<div className="ml-2 mr-3 hidden h-3.5 w-[1px] bg-gray-200 group-hover:block" />
|
||||
<Switch
|
||||
size="l"
|
||||
defaultValue={item.enabled}
|
||||
value={item.enabled ?? false}
|
||||
onChange={(enabled: boolean) => handleSaveExternalDataToolModal({ ...item, enabled }, index)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +260,7 @@ function AppCard({
|
||||
offset={24}
|
||||
>
|
||||
<div>
|
||||
<Switch defaultValue={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
|
||||
<Switch value={runningStatus} onChange={onChangeStatus} disabled={toggleDisabled} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +281,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t('answerIcon.title', { ns: 'app' })}</div>
|
||||
<Switch
|
||||
defaultValue={inputInfo.use_icon_as_answer_icon}
|
||||
value={inputInfo.use_icon_as_answer_icon}
|
||||
onChange={v => setInputInfo({ ...inputInfo, use_icon_as_answer_icon: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,7 +315,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className={cn('body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.chatColorThemeInverted`, { ns: 'appOverview' })}</p>
|
||||
<Switch defaultValue={inputInfo.chatColorThemeInverted} onChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
|
||||
<Switch value={inputInfo.chatColorThemeInverted} onChange={v => setInputInfo({ ...inputInfo, chatColorThemeInverted: v })}></Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,7 +326,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.workflow.subTitle`, { ns: 'appOverview' })}</div>
|
||||
<Switch
|
||||
disabled={!(appInfo.mode === AppModeEnum.WORKFLOW || appInfo.mode === AppModeEnum.ADVANCED_CHAT)}
|
||||
defaultValue={inputInfo.show_workflow_steps}
|
||||
value={inputInfo.show_workflow_steps}
|
||||
onChange={v => setInputInfo({ ...inputInfo, show_workflow_steps: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -380,7 +380,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
>
|
||||
<Switch
|
||||
disabled={!webappCopyrightEnabled}
|
||||
defaultValue={inputInfo.copyrightSwitchValue}
|
||||
value={inputInfo.copyrightSwitchValue}
|
||||
onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -192,7 +192,7 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) {
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Switch
|
||||
defaultValue={trigger.status === 'enabled'}
|
||||
value={trigger.status === 'enabled'}
|
||||
onChange={enabled => onToggleTrigger(trigger, enabled)}
|
||||
disabled={!isCurrentWorkspaceEditor}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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,15 +185,13 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with NuqsTestingAdapter
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||
const renderList = (searchParams = '') => {
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||
{children}
|
||||
</NuqsTestingAdapter>
|
||||
return renderWithNuqs(
|
||||
<List />,
|
||||
{ searchParams, onUrlUpdate },
|
||||
)
|
||||
return render(<List />, { wrapper })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
@@ -391,18 +388,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -70,7 +70,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
const renderSafeContent = (value: string) => {
|
||||
const parts = value.split(/(\{\{[^}]+\}\}|\n)/g)
|
||||
return parts.map((part, index) => {
|
||||
const variableMatch = part.match(/^\{\{([^}]+)\}\}$/)
|
||||
const variableMatch = /^\{\{([^}]+)\}\}$/.exec(part)
|
||||
if (variableMatch) {
|
||||
return (
|
||||
<VarHighlight
|
||||
|
||||
@@ -17,7 +17,7 @@ const ContentItem = ({
|
||||
|
||||
const extractFieldName = (str: string): string => {
|
||||
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
|
||||
const match = str.match(outputVarRegex)
|
||||
const match = outputVarRegex.exec(str)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DaysOfWeek } from './days-of-week'
|
||||
|
||||
describe('DaysOfWeek', () => {
|
||||
// Rendering test
|
||||
describe('Rendering', () => {
|
||||
it('should render 7 day labels', () => {
|
||||
render(<DaysOfWeek />)
|
||||
|
||||
// The global i18n mock returns keys like "time.daysInWeek.Sun"
|
||||
const dayElements = screen.getAllByText(/daysInWeek/)
|
||||
expect(dayElements).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should render each day of the week', () => {
|
||||
render(<DaysOfWeek />)
|
||||
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
days.forEach((day) => {
|
||||
expect(screen.getByText(new RegExp(day))).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { CalendarProps, Day } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Calendar from './index'
|
||||
|
||||
// Mock scrollIntoView since jsdom doesn't implement it
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
// Factory for creating mock days
|
||||
const createMockDays = (count: number = 7): Day[] => {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
date: dayjs('2024-06-01').add(i, 'day'),
|
||||
isCurrentMonth: true,
|
||||
}))
|
||||
}
|
||||
|
||||
// Factory for Calendar props
|
||||
const createCalendarProps = (overrides: Partial<CalendarProps> = {}): CalendarProps => ({
|
||||
days: createMockDays(),
|
||||
selectedDate: undefined,
|
||||
onDateClick: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Calendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render days of week header', () => {
|
||||
const props = createCalendarProps()
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
// DaysOfWeek component renders day labels
|
||||
const dayLabels = screen.getAllByText(/daysInWeek/)
|
||||
expect(dayLabels).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should render all calendar day items', () => {
|
||||
const days = createMockDays(7)
|
||||
const props = createCalendarProps({ days })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should accept wrapperClassName prop without errors', () => {
|
||||
const props = createCalendarProps({ wrapperClassName: 'custom-class' })
|
||||
const { container } = render(<Calendar {...props} />)
|
||||
|
||||
// Verify the component renders successfully with wrapperClassName
|
||||
const dayLabels = screen.getAllByText(/daysInWeek/)
|
||||
expect(dayLabels).toHaveLength(7)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call onDateClick when a day is clicked', () => {
|
||||
const onDateClick = vi.fn()
|
||||
const days = createMockDays(3)
|
||||
const props = createCalendarProps({ days, onDateClick })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
const dayButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(dayButtons[1])
|
||||
|
||||
expect(onDateClick).toHaveBeenCalledTimes(1)
|
||||
expect(onDateClick).toHaveBeenCalledWith(days[1].date)
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled dates tests
|
||||
describe('Disabled Dates', () => {
|
||||
it('should not call onDateClick for disabled dates', () => {
|
||||
const onDateClick = vi.fn()
|
||||
const days = createMockDays(3)
|
||||
// Disable all dates
|
||||
const getIsDateDisabled = vi.fn().mockReturnValue(true)
|
||||
const props = createCalendarProps({ days, onDateClick, getIsDateDisabled })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
const dayButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(dayButtons[0])
|
||||
|
||||
expect(onDateClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass getIsDateDisabled to CalendarItem', () => {
|
||||
const getIsDateDisabled = vi.fn().mockReturnValue(false)
|
||||
const days = createMockDays(2)
|
||||
const props = createCalendarProps({ days, getIsDateDisabled })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
expect(getIsDateDisabled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should render empty calendar when days array is empty', () => {
|
||||
const props = createCalendarProps({ days: [] })
|
||||
render(<Calendar {...props} />)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { CalendarItemProps, Day } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Item from './item'
|
||||
|
||||
const createMockDay = (overrides: Partial<Day> = {}): Day => ({
|
||||
date: dayjs('2024-06-15'),
|
||||
isCurrentMonth: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createItemProps = (overrides: Partial<CalendarItemProps> = {}): CalendarItemProps => ({
|
||||
day: createMockDay(),
|
||||
selectedDate: undefined,
|
||||
onClick: vi.fn(),
|
||||
isDisabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('CalendarItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the day number', () => {
|
||||
const props = createItemProps()
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: '15' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('should have selected styles when date matches selectedDate', () => {
|
||||
const selectedDate = dayjs('2024-06-15')
|
||||
const props = createItemProps({ selectedDate })
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).toHaveClass('bg-components-button-primary-bg', 'text-components-button-primary-text')
|
||||
})
|
||||
|
||||
it('should not have selected styles when date does not match selectedDate', () => {
|
||||
const selectedDate = dayjs('2024-06-16')
|
||||
const props = createItemProps({ selectedDate })
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).not.toHaveClass('bg-components-button-primary-bg', 'text-components-button-primary-text')
|
||||
})
|
||||
|
||||
it('should have different styles when day is not in current month', () => {
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ isCurrentMonth: false }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).toHaveClass('text-text-quaternary')
|
||||
})
|
||||
|
||||
it('should have different styles when day is in current month', () => {
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ isCurrentMonth: true }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
const button = screen.getByRole('button', { name: '15' })
|
||||
expect(button).toHaveClass('text-text-secondary')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click Behavior', () => {
|
||||
it('should call onClick with the date when clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const day = createMockDay()
|
||||
const props = createItemProps({ day, onClick })
|
||||
|
||||
render(<Item {...props} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
expect(onClick).toHaveBeenCalledWith(day.date)
|
||||
})
|
||||
|
||||
it('should not call onClick when isDisabled is true', () => {
|
||||
const onClick = vi.fn()
|
||||
const props = createItemProps({ onClick, isDisabled: true })
|
||||
|
||||
render(<Item {...props} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Today Indicator', () => {
|
||||
it('should render today indicator when date is today', () => {
|
||||
const today = dayjs()
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ date: today }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeInTheDocument()
|
||||
// Today's button should contain a child indicator element
|
||||
expect(button.children.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not render today indicator when date is not today', () => {
|
||||
const notToday = dayjs('2020-01-01')
|
||||
const props = createItemProps({
|
||||
day: createMockDay({ date: notToday }),
|
||||
})
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
// Non-today button should only contain the day number text, no extra children
|
||||
expect(button.children.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle undefined selectedDate', () => {
|
||||
const props = createItemProps({ selectedDate: undefined })
|
||||
|
||||
render(<Item {...props} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OptionListItem from './option-list-item'
|
||||
|
||||
describe('OptionListItem', () => {
|
||||
let originalScrollIntoView: Element['scrollIntoView']
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
originalScrollIntoView = Element.prototype.scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render children content', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Test Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Item')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render as a list item element', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('listitem')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection State', () => {
|
||||
it('should have selected styles when isSelected is true', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={true} onClick={vi.fn()}>
|
||||
Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
expect(item).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
it('should not have selected styles when isSelected is false', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Not Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
expect(item).not.toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Auto-Scroll', () => {
|
||||
it('should scroll into view on mount when isSelected is true', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={true} onClick={vi.fn()}>
|
||||
Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant' })
|
||||
})
|
||||
|
||||
it('should not scroll into view on mount when isSelected is false', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Not Selected
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not scroll into view on mount when noAutoScroll is true', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={true} noAutoScroll onClick={vi.fn()}>
|
||||
No Scroll
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click Behavior', () => {
|
||||
it('should call onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={handleClick}>
|
||||
Clickable
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should scroll into view with smooth behavior on click', () => {
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={vi.fn()}>
|
||||
Item
|
||||
</OptionListItem>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('listitem'))
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle rapid clicks without errors', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<OptionListItem isSelected={false} onClick={handleClick}>
|
||||
Rapid Click
|
||||
</OptionListItem>,
|
||||
)
|
||||
|
||||
const item = screen.getByRole('listitem')
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
fireEvent.click(item)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { DatePickerFooterProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ViewType } from '../types'
|
||||
import Footer from './footer'
|
||||
|
||||
// Factory for Footer props
|
||||
const createFooterProps = (overrides: Partial<DatePickerFooterProps> = {}): DatePickerFooterProps => ({
|
||||
needTimePicker: true,
|
||||
displayTime: '02:30 PM',
|
||||
view: ViewType.date,
|
||||
handleClickTimePicker: vi.fn(),
|
||||
handleSelectCurrentDate: vi.fn(),
|
||||
handleConfirmDate: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DatePicker Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render Now button and confirm button', () => {
|
||||
const props = createFooterProps()
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.now/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.ok/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show time picker button when needTimePicker is true', () => {
|
||||
const props = createFooterProps({ needTimePicker: true, displayTime: '02:30 PM' })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText('02:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show time picker button when needTimePicker is false', () => {
|
||||
const props = createFooterProps({ needTimePicker: false })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.queryByText('02:30 PM')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// View-dependent rendering tests
|
||||
describe('View States', () => {
|
||||
it('should show display time when view is date', () => {
|
||||
const props = createFooterProps({ view: ViewType.date, displayTime: '10:00 AM' })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText('10:00 AM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show pickDate text when view is time', () => {
|
||||
const props = createFooterProps({ view: ViewType.time })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleClickTimePicker when time picker button is clicked', () => {
|
||||
const handleClickTimePicker = vi.fn()
|
||||
const props = createFooterProps({ handleClickTimePicker })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
// Click the time picker toggle button (has the time display)
|
||||
fireEvent.click(screen.getByText('02:30 PM'))
|
||||
|
||||
expect(handleClickTimePicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleSelectCurrentDate when Now button is clicked', () => {
|
||||
const handleSelectCurrentDate = vi.fn()
|
||||
const props = createFooterProps({ handleSelectCurrentDate })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.now/))
|
||||
|
||||
expect(handleSelectCurrentDate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleConfirmDate when OK button is clicked', () => {
|
||||
const handleConfirmDate = vi.fn()
|
||||
const props = createFooterProps({ handleConfirmDate })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(handleConfirmDate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { DatePickerHeaderProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Header from './header'
|
||||
|
||||
// Factory for Header props
|
||||
const createHeaderProps = (overrides: Partial<DatePickerHeaderProps> = {}): DatePickerHeaderProps => ({
|
||||
handleOpenYearMonthPicker: vi.fn(),
|
||||
currentDate: dayjs('2024-06-15'),
|
||||
onClickNextMonth: vi.fn(),
|
||||
onClickPrevMonth: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('DatePicker Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render month and year display', () => {
|
||||
const props = createHeaderProps({ currentDate: dayjs('2024-06-15') })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// The useMonths hook returns translated keys; check for year
|
||||
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render navigation buttons', () => {
|
||||
const props = createHeaderProps()
|
||||
render(<Header {...props} />)
|
||||
|
||||
// There are 3 buttons: month/year display, prev, next
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleOpenYearMonthPicker when month/year button is clicked', () => {
|
||||
const handleOpenYearMonthPicker = vi.fn()
|
||||
const props = createHeaderProps({ handleOpenYearMonthPicker })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// First button is the month/year display
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
|
||||
expect(handleOpenYearMonthPicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClickPrevMonth when previous button is clicked', () => {
|
||||
const onClickPrevMonth = vi.fn()
|
||||
const props = createHeaderProps({ onClickPrevMonth })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// Second button is prev month
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
expect(onClickPrevMonth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onClickNextMonth when next button is clicked', () => {
|
||||
const onClickNextMonth = vi.fn()
|
||||
const props = createHeaderProps({ onClickNextMonth })
|
||||
render(<Header {...props} />)
|
||||
|
||||
// Third button is next month
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[2])
|
||||
|
||||
expect(onClickNextMonth).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,616 @@
|
||||
import type { DatePickerProps } from '../types'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import DatePicker from './index'
|
||||
|
||||
// Mock scrollIntoView
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
// Factory for DatePicker props
|
||||
const createDatePickerProps = (overrides: Partial<DatePickerProps> = {}): DatePickerProps => ({
|
||||
value: undefined,
|
||||
onChange: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Helper to open the picker
|
||||
const openPicker = () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.click(input)
|
||||
}
|
||||
|
||||
describe('DatePicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render with default placeholder', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with custom placeholder', () => {
|
||||
const props = createDatePickerProps({ placeholder: 'Select date' })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('placeholder', 'Select date')
|
||||
})
|
||||
|
||||
it('should display formatted date value when value is provided', () => {
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({ value })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox').getAttribute('value')).not.toBe('')
|
||||
})
|
||||
|
||||
it('should render with empty value when no value is provided', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should normalize value with timezone applied', () => {
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({ value, timezone: 'America/New_York' })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox').getAttribute('value')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// Open/close behavior
|
||||
describe('Open/Close Behavior', () => {
|
||||
it('should open the picker when trigger is clicked', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should close when trigger is clicked while open', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
openPicker() // second click closes
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should restore selected date from value when reopening', () => {
|
||||
const value = dayjs('2024-06-15')
|
||||
const props = createDatePickerProps({ value })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Calendar should be showing June 2024
|
||||
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close when clicking outside the container', () => {
|
||||
const props = createDatePickerProps()
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Simulate a mousedown event outside the container
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
})
|
||||
|
||||
// The picker should now be closed - input shows its value
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Time Picker Integration
|
||||
describe('Time Picker Integration', () => {
|
||||
it('should show time display in footer when needTimePicker is true', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.getByText('--:-- --')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show time toggle when needTimePicker is false', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: false })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.queryByText('--:-- --')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to time view when time picker button is clicked', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click the time display button to switch to time view
|
||||
fireEvent.click(screen.getByText('--:-- --'))
|
||||
|
||||
// In time view, the "pickDate" text should appear instead of the time
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch back to date view when pickDate is clicked in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText('--:-- --'))
|
||||
// Switch back to date view
|
||||
fireEvent.click(screen.getByText(/operation\.pickDate/))
|
||||
|
||||
// Days of week should be visible again
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render time picker options in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Should show AM/PM options (TimePickerOptions renders these)
|
||||
expect(screen.getByText('AM')).toBeInTheDocument()
|
||||
expect(screen.getByText('PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selected time when hour is selected in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Click hour "05" from the time options
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const hourItems = within(allLists[0]).getAllByRole('listitem')
|
||||
fireEvent.click(hourItems[4])
|
||||
|
||||
// The picker should still be in time view
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selected time when minute is selected in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Click minute "45" from the time options
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const minuteItems = within(allLists[1]).getAllByRole('listitem')
|
||||
fireEvent.click(minuteItems[45])
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update selected time when period is changed in time view', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true, value: dayjs('2024-06-15T14:30:00') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view
|
||||
fireEvent.click(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/))
|
||||
|
||||
// Click AM to switch period
|
||||
fireEvent.click(screen.getByText('AM'))
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update time when no selectedDate exists and hour is selected', () => {
|
||||
const props = createDatePickerProps({ needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Switch to time view (click on the "--:-- --" text)
|
||||
fireEvent.click(screen.getByText('--:-- --'))
|
||||
|
||||
// Click hour "03" from the time options
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const hourItems = within(allLists[0]).getAllByRole('listitem')
|
||||
fireEvent.click(hourItems[2])
|
||||
|
||||
expect(screen.getByText(/operation\.pickDate/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Date selection
|
||||
describe('Date Selection', () => {
|
||||
it('should call onChange when Now button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/operation\.now/))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onChange when OK button is clicked with a value', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange, value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should select a calendar day when clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange, value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click on a day in the calendar - day "20"
|
||||
const dayButton = screen.getByRole('button', { name: '20' })
|
||||
fireEvent.click(dayButton)
|
||||
|
||||
// The date should now appear in the header/display
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should immediately confirm when noConfirm is true and a date is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange, noConfirm: true, value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click on a day
|
||||
const dayButton = screen.getByRole('button', { name: '20' })
|
||||
fireEvent.click(dayButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onChange with undefined when OK is clicked without a selected date', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({ onChange })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Clear selected date then confirm
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Clear behavior
|
||||
describe('Clear Behavior', () => {
|
||||
it('should call onClear when clear is clicked while picker is closed', () => {
|
||||
const onClear = vi.fn()
|
||||
const renderTrigger = vi.fn(({ handleClear }) => (
|
||||
<button data-testid="clear-trigger" onClick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
))
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
onClear,
|
||||
renderTrigger,
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('clear-trigger'))
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear selected date without calling onClear when picker is open', () => {
|
||||
const onClear = vi.fn()
|
||||
const onChange = vi.fn()
|
||||
const renderTrigger = vi.fn(({ handleClickTrigger, handleClear }) => (
|
||||
<div>
|
||||
<button data-testid="open-trigger" onClick={handleClickTrigger}>
|
||||
Open
|
||||
</button>
|
||||
<button data-testid="clear-trigger" onClick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
onClear,
|
||||
onChange,
|
||||
renderTrigger,
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('open-trigger'))
|
||||
fireEvent.click(screen.getByTestId('clear-trigger'))
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
expect(onChange).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
// Month navigation
|
||||
describe('Month Navigation', () => {
|
||||
it('should navigate to next month when next arrow is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Find navigation buttons in the header
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
// The header has: month/year button, prev button, next button
|
||||
// Then calendar days are also buttons. We need the 3rd button (next month).
|
||||
// Header buttons come first in DOM order.
|
||||
fireEvent.click(allButtons[2]) // next month button
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should navigate to previous month when prev arrow is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(allButtons[1]) // prev month button
|
||||
|
||||
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Year/Month picker
|
||||
describe('Year/Month Picker', () => {
|
||||
it('should open year/month picker when month/year header is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
const headerButton = screen.getByText(/2024/)
|
||||
fireEvent.click(headerButton)
|
||||
|
||||
// Cancel button visible in year/month picker footer
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close year/month picker when cancel is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// Cancel
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
// Should be back to date view with days of week
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should confirm year/month selection when OK is clicked', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// Select a different year
|
||||
fireEvent.click(screen.getByText('2023'))
|
||||
|
||||
// Confirm - click the last OK button (year/month footer)
|
||||
const okButtons = screen.getAllByText(/operation\.ok/)
|
||||
fireEvent.click(okButtons[okButtons.length - 1])
|
||||
|
||||
// Should return to date view
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should close year/month picker by clicking header button', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
// Open year/month picker
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// The header in year/month view shows selected month/year with an up arrow
|
||||
// Clicking it closes the year/month picker
|
||||
const headerButtons = screen.getAllByRole('button')
|
||||
fireEvent.click(headerButtons[0]) // First button in year/month view is the header
|
||||
|
||||
// Should return to date view
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should update month selection in year/month picker', () => {
|
||||
const props = createDatePickerProps({ value: dayjs('2024-06-15') })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
fireEvent.click(screen.getByText(/2024/))
|
||||
|
||||
// Select a different month using RTL queries
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const monthItems = within(allLists[0]).getAllByRole('listitem')
|
||||
fireEvent.click(monthItems[0])
|
||||
|
||||
// Confirm the selection - click the last OK button (year/month footer)
|
||||
const okButtons = screen.getAllByText(/operation\.ok/)
|
||||
fireEvent.click(okButtons[okButtons.length - 1])
|
||||
|
||||
// Should return to date view
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// noConfirm mode
|
||||
describe('noConfirm Mode', () => {
|
||||
it('should not show footer when noConfirm is true', () => {
|
||||
const props = createDatePickerProps({ noConfirm: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(screen.queryByText(/operation\.ok/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Custom trigger
|
||||
describe('Custom Trigger', () => {
|
||||
it('should use renderTrigger when provided', () => {
|
||||
const renderTrigger = vi.fn(({ handleClickTrigger }) => (
|
||||
<button data-testid="custom-trigger" onClick={handleClickTrigger}>
|
||||
Custom
|
||||
</button>
|
||||
))
|
||||
|
||||
const props = createDatePickerProps({ renderTrigger })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open picker when custom trigger is clicked', () => {
|
||||
const renderTrigger = vi.fn(({ handleClickTrigger }) => (
|
||||
<button data-testid="custom-trigger" onClick={handleClickTrigger}>
|
||||
Custom
|
||||
</button>
|
||||
))
|
||||
|
||||
const props = createDatePickerProps({ renderTrigger })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom-trigger'))
|
||||
|
||||
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled dates
|
||||
describe('Disabled Dates', () => {
|
||||
it('should pass getIsDateDisabled to calendar', () => {
|
||||
const getIsDateDisabled = vi.fn().mockReturnValue(false)
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
getIsDateDisabled,
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
expect(getIsDateDisabled).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Timezone
|
||||
describe('Timezone', () => {
|
||||
it('should render with timezone', () => {
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15'),
|
||||
timezone: 'UTC',
|
||||
})
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onChange when timezone changes with a value', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({
|
||||
value: dayjs('2024-06-15T14:30:00'),
|
||||
timezone: 'UTC',
|
||||
onChange,
|
||||
})
|
||||
const { rerender } = render(<DatePicker {...props} />)
|
||||
|
||||
// Change timezone
|
||||
rerender(<DatePicker {...props} timezone="America/New_York" />)
|
||||
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update currentDate when timezone changes without a value', () => {
|
||||
const onChange = vi.fn()
|
||||
const props = createDatePickerProps({
|
||||
timezone: 'UTC',
|
||||
onChange,
|
||||
})
|
||||
const { rerender } = render(<DatePicker {...props} />)
|
||||
|
||||
// Change timezone with no value
|
||||
rerender(<DatePicker {...props} timezone="America/New_York" />)
|
||||
|
||||
// onChange should NOT be called when there is no value
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update selectedDate when timezone changes and value is present', () => {
|
||||
const onChange = vi.fn()
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({
|
||||
value,
|
||||
timezone: 'UTC',
|
||||
onChange,
|
||||
})
|
||||
const { rerender } = render(<DatePicker {...props} />)
|
||||
|
||||
// Change timezone
|
||||
rerender(<DatePicker {...props} timezone="Asia/Tokyo" />)
|
||||
|
||||
// Should have been called with the new timezone-adjusted value
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(emitted.isValid()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Display time when selected date exists
|
||||
describe('Time Display', () => {
|
||||
it('should show formatted time when selectedDate exists', () => {
|
||||
const value = dayjs('2024-06-15T14:30:00')
|
||||
const props = createDatePickerProps({ value, needTimePicker: true })
|
||||
render(<DatePicker {...props} />)
|
||||
|
||||
openPicker()
|
||||
|
||||
// The footer should show the time from selectedDate (02:30 PM)
|
||||
expect(screen.getByText(/\d{2}:\d{2}\s(AM|PM)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
94
web/app/components/base/date-and-time-picker/hooks.spec.ts
Normal file
94
web/app/components/base/date-and-time-picker/hooks.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useDaysOfWeek, useMonths, useTimeOptions, useYearOptions } from './hooks'
|
||||
import { Period } from './types'
|
||||
import dayjs from './utils/dayjs'
|
||||
|
||||
describe('date-and-time-picker hooks', () => {
|
||||
// Tests for useDaysOfWeek hook
|
||||
describe('useDaysOfWeek', () => {
|
||||
it('should return 7 days of the week', () => {
|
||||
const { result } = renderHook(() => useDaysOfWeek())
|
||||
|
||||
expect(result.current).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('should return translated day keys with namespace prefix', () => {
|
||||
const { result } = renderHook(() => useDaysOfWeek())
|
||||
|
||||
// Global i18n mock returns "time.daysInWeek.<day>" format
|
||||
expect(result.current[0]).toContain('daysInWeek.Sun')
|
||||
expect(result.current[6]).toContain('daysInWeek.Sat')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for useMonths hook
|
||||
describe('useMonths', () => {
|
||||
it('should return 12 months', () => {
|
||||
const { result } = renderHook(() => useMonths())
|
||||
|
||||
expect(result.current).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('should return translated month keys with namespace prefix', () => {
|
||||
const { result } = renderHook(() => useMonths())
|
||||
|
||||
expect(result.current[0]).toContain('months.January')
|
||||
expect(result.current[11]).toContain('months.December')
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for useYearOptions hook
|
||||
describe('useYearOptions', () => {
|
||||
it('should return 200 year options', () => {
|
||||
const { result } = renderHook(() => useYearOptions())
|
||||
|
||||
expect(result.current).toHaveLength(200)
|
||||
})
|
||||
|
||||
it('should center around the current year', () => {
|
||||
const { result } = renderHook(() => useYearOptions())
|
||||
const currentYear = dayjs().year()
|
||||
|
||||
expect(result.current).toContain(currentYear)
|
||||
// First year should be currentYear - 50 (YEAR_RANGE/2 = 50)
|
||||
expect(result.current[0]).toBe(currentYear - 50)
|
||||
// Last year should be currentYear + 149
|
||||
expect(result.current[199]).toBe(currentYear + 149)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for useTimeOptions hook
|
||||
describe('useTimeOptions', () => {
|
||||
it('should return 12 hour options', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.hourOptions).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('should return hours from 01 to 12 zero-padded', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.hourOptions[0]).toBe('01')
|
||||
expect(result.current.hourOptions[11]).toBe('12')
|
||||
})
|
||||
|
||||
it('should return 60 minute options', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.minuteOptions).toHaveLength(60)
|
||||
})
|
||||
|
||||
it('should return minutes from 00 to 59 zero-padded', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.minuteOptions[0]).toBe('00')
|
||||
expect(result.current.minuteOptions[59]).toBe('59')
|
||||
})
|
||||
|
||||
it('should return AM and PM period options', () => {
|
||||
const { result } = renderHook(() => useTimeOptions())
|
||||
|
||||
expect(result.current.periodOptions).toEqual([Period.AM, Period.PM])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { TimePickerFooterProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
// Factory for TimePickerFooter props
|
||||
const createFooterProps = (overrides: Partial<TimePickerFooterProps> = {}): TimePickerFooterProps => ({
|
||||
handleSelectCurrentTime: vi.fn(),
|
||||
handleConfirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TimePicker Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render Now and OK buttons', () => {
|
||||
const props = createFooterProps()
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.now/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.ok/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleSelectCurrentTime when Now button is clicked', () => {
|
||||
const handleSelectCurrentTime = vi.fn()
|
||||
const props = createFooterProps({ handleSelectCurrentTime })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.now/))
|
||||
|
||||
expect(handleSelectCurrentTime).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleConfirm when OK button is clicked', () => {
|
||||
const handleConfirm = vi.fn()
|
||||
const props = createFooterProps({ handleConfirm })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(handleConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Header from './header'
|
||||
|
||||
describe('TimePicker Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render default title when no title prop is provided', () => {
|
||||
render(<Header />)
|
||||
|
||||
// Global i18n mock returns the key with namespace prefix
|
||||
expect(screen.getByText(/title\.pickTime/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom title when title prop is provided', () => {
|
||||
render(<Header title="Custom Title" />)
|
||||
|
||||
expect(screen.getByText('Custom Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render default title when custom title is provided', () => {
|
||||
render(<Header title="Custom Title" />)
|
||||
|
||||
expect(screen.queryByText(/title\.pickTime/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,42 +1,12 @@
|
||||
import type { TimePickerProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import dayjs, { isDayjsObject } from '../utils/dayjs'
|
||||
import TimePicker from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => {
|
||||
if (key === 'defaultPlaceholder')
|
||||
return 'Pick a time...'
|
||||
if (key === 'operation.now')
|
||||
return 'Now'
|
||||
if (key === 'operation.ok')
|
||||
return 'OK'
|
||||
if (key === 'operation.clear')
|
||||
return 'Clear'
|
||||
const prefix = options?.ns ? `${options.ns}.` : ''
|
||||
return `${prefix}${key}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
|
||||
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: (e: React.MouseEvent) => void }) => (
|
||||
<div onClick={onClick}>{children}</div>
|
||||
),
|
||||
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="timepicker-content">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./options', () => ({
|
||||
default: () => <div data-testid="time-options" />,
|
||||
}))
|
||||
vi.mock('./header', () => ({
|
||||
default: () => <div data-testid="time-header" />,
|
||||
}))
|
||||
// Mock scrollIntoView since jsdom doesn't implement it
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
describe('TimePicker', () => {
|
||||
const baseProps: Pick<TimePickerProps, 'onChange' | 'onClear' | 'value'> = {
|
||||
@@ -73,10 +43,10 @@ describe('TimePicker', () => {
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.click(input)
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear/i })
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: 'OK' })
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(baseProps.onChange).toHaveBeenCalledTimes(1)
|
||||
@@ -94,7 +64,10 @@ describe('TimePicker', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const nowButton = screen.getByRole('button', { name: 'Now' })
|
||||
// Open the picker first to access content
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
|
||||
const nowButton = screen.getByRole('button', { name: /operation\.now/i })
|
||||
fireEvent.click(nowButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
@@ -103,6 +76,601 @@ describe('TimePicker', () => {
|
||||
expect(emitted?.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
|
||||
})
|
||||
|
||||
// Opening and closing behavior tests
|
||||
describe('Open/Close Behavior', () => {
|
||||
it('should show placeholder when no value is provided', () => {
|
||||
render(<TimePicker {...baseProps} />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', expect.stringMatching(/defaultPlaceholder/i))
|
||||
})
|
||||
|
||||
it('should toggle open state when trigger is clicked', () => {
|
||||
render(<TimePicker {...baseProps} value="10:00 AM" timezone="UTC" />)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Open
|
||||
fireEvent.click(input)
|
||||
expect(input).toHaveValue('')
|
||||
|
||||
// Close by clicking again
|
||||
fireEvent.click(input)
|
||||
expect(input).toHaveValue('10:00 AM')
|
||||
})
|
||||
|
||||
it('should call onClear when clear is clicked while picker is closed', () => {
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onClear={onClear}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onClear when clear is clicked while picker is open', () => {
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onClear={onClear}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open picker first
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
// Then clear
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(onClear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should register click outside listener on mount', () => {
|
||||
const addEventSpy = vi.spyOn(document, 'addEventListener')
|
||||
render(<TimePicker {...baseProps} value="10:00 AM" timezone="UTC" />)
|
||||
|
||||
expect(addEventSpy).toHaveBeenCalledWith('mousedown', expect.any(Function))
|
||||
addEventSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should sync selectedTime from value when opening with stale state', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Open - this triggers handleClickTrigger which syncs selectedTime from value
|
||||
fireEvent.click(input)
|
||||
|
||||
// Confirm to verify selectedTime was synced from value prop ("10:00 AM")
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBe(10)
|
||||
expect(emitted.minute()).toBe(0)
|
||||
})
|
||||
|
||||
it('should resync selectedTime when opening after internal clear', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
// Open
|
||||
fireEvent.click(input)
|
||||
|
||||
// Clear selected time internally
|
||||
const clearButton = screen.getByRole('button', { name: /operation\.clear/i })
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
// Close
|
||||
fireEvent.click(input)
|
||||
|
||||
// Open again - should resync selectedTime from value prop
|
||||
fireEvent.click(input)
|
||||
|
||||
// Confirm to verify the value was resynced
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// Resynced from value prop: dayjs('2024-01-01T10:30:00Z') in UTC = 10:30 AM
|
||||
expect(emitted.hour()).toBe(10)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
// Props tests
|
||||
describe('Props', () => {
|
||||
it('should show custom placeholder when provided', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
placeholder="Select time"
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('placeholder', 'Select time')
|
||||
})
|
||||
|
||||
it('should render with triggerFullWidth prop without errors', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
triggerFullWidth={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Verify the component renders successfully with triggerFullWidth
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use renderTrigger when provided', () => {
|
||||
const renderTrigger = vi.fn(({ inputElem, onClick }) => (
|
||||
<div data-testid="custom-trigger" onClick={onClick}>
|
||||
{inputElem}
|
||||
</div>
|
||||
))
|
||||
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
renderTrigger={renderTrigger}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
|
||||
expect(renderTrigger).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render with notClearable prop without errors', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
notClearable={true}
|
||||
value="10:00 AM"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// In test env the icon stays in DOM, but must remain hidden when notClearable is set
|
||||
expect(screen.getByRole('button', { name: /clear/i })).toHaveClass('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
// Confirm behavior tests
|
||||
describe('Confirm Behavior', () => {
|
||||
it('should emit selected time when confirm is clicked with a value', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Open the picker first to access content
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBe(10)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
})
|
||||
|
||||
// Time selection handler tests
|
||||
describe('Time Selection', () => {
|
||||
const openPicker = () => {
|
||||
fireEvent.click(screen.getByRole('textbox'))
|
||||
}
|
||||
|
||||
const getHourAndMinuteLists = () => {
|
||||
const allLists = screen.getAllByRole('list')
|
||||
const hourList = allLists.find(list =>
|
||||
within(list).queryByText('01')
|
||||
&& within(list).queryByText('12')
|
||||
&& !within(list).queryByText('59'))
|
||||
const minuteList = allLists.find(list =>
|
||||
within(list).queryByText('00')
|
||||
&& within(list).queryByText('59'))
|
||||
|
||||
expect(hourList).toBeTruthy()
|
||||
expect(minuteList).toBeTruthy()
|
||||
|
||||
return {
|
||||
hourList: hourList!,
|
||||
minuteList: minuteList!,
|
||||
}
|
||||
}
|
||||
|
||||
it('should update selectedTime when hour is selected', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click hour "05" from the time options
|
||||
const { hourList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(hourList).getByText('05'))
|
||||
|
||||
// Now confirm to verify the selectedTime was updated
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// Hour 05 in AM (since original was 10:30 AM) = 5
|
||||
expect(emitted.hour()).toBe(5)
|
||||
})
|
||||
|
||||
it('should update selectedTime when minute is selected', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click minute "45" from the time options
|
||||
const { minuteList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(minuteList).getByText('45'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(emitted.minute()).toBe(45)
|
||||
})
|
||||
|
||||
it('should update selectedTime when period is changed', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click PM to switch period
|
||||
fireEvent.click(screen.getByText('PM'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
// Original was 10:30 AM, switching to PM makes it 22:30
|
||||
expect(emitted.hour()).toBe(22)
|
||||
})
|
||||
|
||||
it('should create new time when selecting hour without prior selectedTime', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click hour "03" with no existing selectedTime
|
||||
const { hourList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(hourList).getByText('03'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBe(3)
|
||||
})
|
||||
|
||||
it('should handle minute selection without prior selectedTime', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click minute "15" with no existing selectedTime
|
||||
const { minuteList } = getHourAndMinuteLists()
|
||||
fireEvent.click(within(minuteList).getByText('15'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(emitted.minute()).toBe(15)
|
||||
})
|
||||
|
||||
it('should handle period selection without prior selectedTime', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
openPicker()
|
||||
|
||||
// Click PM with no existing selectedTime
|
||||
fireEvent.click(screen.getByText('PM'))
|
||||
|
||||
// Confirm
|
||||
const confirmButton = screen.getByRole('button', { name: /operation\.ok/i })
|
||||
fireEvent.click(confirmButton)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
expect(emitted.hour()).toBeGreaterThanOrEqual(12)
|
||||
})
|
||||
})
|
||||
|
||||
// Timezone change effect tests
|
||||
describe('Timezone Changes', () => {
|
||||
it('should call onChange when timezone changes with an existing value', () => {
|
||||
const onChange = vi.fn()
|
||||
const value = dayjs('2024-01-01T10:30:00Z')
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change timezone without changing value (same reference)
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const emitted = onChange.mock.calls[0][0]
|
||||
expect(isDayjsObject(emitted)).toBe(true)
|
||||
// 10:30 UTC converted to America/New_York (UTC-5 in Jan) = 05:30
|
||||
expect(emitted.utcOffset()).toBe(dayjs().tz('America/New_York').utcOffset())
|
||||
expect(emitted.hour()).toBe(5)
|
||||
expect(emitted.minute()).toBe(30)
|
||||
})
|
||||
|
||||
it('should update selectedTime when value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change value
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T14:00:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// onChange should not be called when only value changes (no timezone change)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
// But the display should update
|
||||
expect(screen.getByDisplayValue('02:00 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle timezone change when value is undefined', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change timezone without a value
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
// onChange should not be called when value is undefined
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle timezone change when selectedTime exists but value becomes undefined', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
// Remove value and change timezone
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={undefined}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
// Input should be empty now
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
// onChange should not fire when value is undefined, even if selectedTime was set
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update when neither timezone nor value changes', () => {
|
||||
const onChange = vi.fn()
|
||||
const value = dayjs('2024-01-01T10:30:00Z')
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Rerender with same props
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update display when both value and timezone change', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T10:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Change both value and timezone simultaneously
|
||||
rerender(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
value={dayjs('2024-01-01T15:00:00Z')}
|
||||
timezone="America/New_York"
|
||||
/>,
|
||||
)
|
||||
|
||||
// onChange should not be called since both changed (timezoneChanged && !valueChanged is false)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
|
||||
// 15:00 UTC in America/New_York (UTC-5) = 10:00 AM
|
||||
expect(screen.getByDisplayValue('10:00 AM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Format time value tests
|
||||
describe('Format Time Value', () => {
|
||||
it('should return empty string when value is undefined', () => {
|
||||
render(<TimePicker {...baseProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should format dayjs value correctly', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value={dayjs('2024-01-01T14:30:00Z')}
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('02:30 PM')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format string value correctly', () => {
|
||||
render(
|
||||
<TimePicker
|
||||
{...baseProps}
|
||||
value="09:15"
|
||||
timezone="UTC"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('09:15 AM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Timezone Label Integration', () => {
|
||||
it('should not display timezone label by default', () => {
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { TimeOptionsProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import dayjs from '../utils/dayjs'
|
||||
import Options from './options'
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
const createOptionsProps = (overrides: Partial<TimeOptionsProps> = {}): TimeOptionsProps => ({
|
||||
selectedTime: undefined,
|
||||
handleSelectHour: vi.fn(),
|
||||
handleSelectMinute: vi.fn(),
|
||||
handleSelectPeriod: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('TimePickerOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render hour options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems.length).toBeGreaterThan(12)
|
||||
})
|
||||
|
||||
it('should render all hour, minute, and period options by default', () => {
|
||||
const props = createOptionsProps()
|
||||
render(<Options {...props} />)
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
// 12 hours + 60 minutes + 2 periods
|
||||
expect(allItems).toHaveLength(74)
|
||||
})
|
||||
|
||||
it('should render AM and PM period options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
expect(screen.getByText('AM')).toBeInTheDocument()
|
||||
expect(screen.getByText('PM')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Minute Filter', () => {
|
||||
it('should apply minuteFilter when provided', () => {
|
||||
const minuteFilter = (minutes: string[]) => minutes.filter(m => Number(m) % 15 === 0)
|
||||
const props = createOptionsProps({ minuteFilter })
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should render selected hour in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedHour = screen.getAllByRole('listitem').find(item => item.textContent === '05')
|
||||
expect(selectedHour).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
it('should render selected minute in the list', () => {
|
||||
const props = createOptionsProps({ selectedTime: dayjs('2024-01-01 05:30:00') })
|
||||
render(<Options {...props} />)
|
||||
const selectedMinute = screen.getAllByRole('listitem').find(item => item.textContent === '30')
|
||||
expect(selectedMinute).toHaveClass('bg-components-button-ghost-bg-hover')
|
||||
})
|
||||
|
||||
it('should call handleSelectPeriod when AM is clicked', () => {
|
||||
const handleSelectPeriod = vi.fn()
|
||||
const props = createOptionsProps({ handleSelectPeriod })
|
||||
|
||||
render(<Options {...props} />)
|
||||
fireEvent.click(screen.getAllByText('AM')[0])
|
||||
|
||||
expect(handleSelectPeriod).toHaveBeenCalledWith('AM')
|
||||
})
|
||||
|
||||
it('should call handleSelectPeriod when PM is clicked', () => {
|
||||
const handleSelectPeriod = vi.fn()
|
||||
const props = createOptionsProps({ handleSelectPeriod })
|
||||
|
||||
render(<Options {...props} />)
|
||||
fireEvent.click(screen.getAllByText('PM')[0])
|
||||
|
||||
expect(handleSelectPeriod).toHaveBeenCalledWith('PM')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,366 @@
|
||||
import dayjs, {
|
||||
clearMonthMapCache,
|
||||
cloneTime,
|
||||
formatDateForOutput,
|
||||
getDateWithTimezone,
|
||||
getDaysInMonth,
|
||||
getHourIn12Hour,
|
||||
parseDateWithFormat,
|
||||
toDayjs,
|
||||
} from './dayjs'
|
||||
|
||||
describe('dayjs extended utilities', () => {
|
||||
// Tests for cloneTime
|
||||
describe('cloneTime', () => {
|
||||
it('should copy hour and minute from source to target', () => {
|
||||
const target = dayjs('2024-01-15')
|
||||
const source = dayjs('2024-06-20 14:30')
|
||||
|
||||
const result = cloneTime(target, source)
|
||||
|
||||
expect(result.hour()).toBe(14)
|
||||
expect(result.minute()).toBe(30)
|
||||
expect(result.date()).toBe(15)
|
||||
expect(result.month()).toBe(0) // January
|
||||
})
|
||||
|
||||
it('should not mutate the original target date', () => {
|
||||
const target = dayjs('2024-01-15 08:00')
|
||||
const source = dayjs('2024-06-20 14:30')
|
||||
|
||||
cloneTime(target, source)
|
||||
|
||||
expect(target.hour()).toBe(8)
|
||||
expect(target.minute()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getDaysInMonth
|
||||
describe('getDaysInMonth', () => {
|
||||
beforeEach(() => {
|
||||
clearMonthMapCache()
|
||||
})
|
||||
|
||||
it('should return an array of Day objects', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const days = getDaysInMonth(date)
|
||||
|
||||
expect(days.length).toBeGreaterThan(0)
|
||||
days.forEach((day) => {
|
||||
expect(day).toHaveProperty('date')
|
||||
expect(day).toHaveProperty('isCurrentMonth')
|
||||
})
|
||||
})
|
||||
|
||||
it('should include days from previous and next month to fill the grid', () => {
|
||||
const date = dayjs('2024-06-15') // June 2024 starts on Saturday
|
||||
const days = getDaysInMonth(date)
|
||||
|
||||
const prevMonthDays = days.filter(d => !d.isCurrentMonth && d.date.month() < date.month())
|
||||
const nextMonthDays = days.filter(d => !d.isCurrentMonth && d.date.month() > date.month())
|
||||
|
||||
// June 2024 starts on Saturday (6), so there are 6 days from previous month
|
||||
expect(prevMonthDays.length).toBeGreaterThan(0)
|
||||
expect(nextMonthDays.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should mark current month days correctly', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const days = getDaysInMonth(date)
|
||||
|
||||
const currentMonthDays = days.filter(d => d.isCurrentMonth)
|
||||
// June has 30 days
|
||||
expect(currentMonthDays).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('should cache results for the same month', () => {
|
||||
const date1 = dayjs('2024-06-15')
|
||||
const date2 = dayjs('2024-06-20')
|
||||
|
||||
const days1 = getDaysInMonth(date1)
|
||||
const days2 = getDaysInMonth(date2)
|
||||
|
||||
// Same reference since it's cached
|
||||
expect(days1).toBe(days2)
|
||||
})
|
||||
|
||||
it('should return different results for different months', () => {
|
||||
const june = dayjs('2024-06-15')
|
||||
const july = dayjs('2024-07-15')
|
||||
|
||||
const juneDays = getDaysInMonth(june)
|
||||
const julyDays = getDaysInMonth(july)
|
||||
|
||||
expect(juneDays).not.toBe(julyDays)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for clearMonthMapCache
|
||||
describe('clearMonthMapCache', () => {
|
||||
it('should clear the cache so new days are generated', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
|
||||
const days1 = getDaysInMonth(date)
|
||||
clearMonthMapCache()
|
||||
const days2 = getDaysInMonth(date)
|
||||
|
||||
// After clearing cache, a new array should be created
|
||||
expect(days1).not.toBe(days2)
|
||||
// But should have the same length
|
||||
expect(days1.length).toBe(days2.length)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getHourIn12Hour
|
||||
describe('getHourIn12Hour', () => {
|
||||
it('should return 12 for midnight (hour 0)', () => {
|
||||
const date = dayjs('2024-01-01 00:00')
|
||||
expect(getHourIn12Hour(date)).toBe(12)
|
||||
})
|
||||
|
||||
it('should return hour as-is for 1-11 AM', () => {
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 01:00'))).toBe(1)
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 11:00'))).toBe(11)
|
||||
})
|
||||
|
||||
it('should return 0 for noon (hour 12)', () => {
|
||||
const date = dayjs('2024-01-01 12:00')
|
||||
expect(getHourIn12Hour(date)).toBe(0)
|
||||
})
|
||||
|
||||
it('should return hour - 12 for PM hours (13-23)', () => {
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 13:00'))).toBe(1)
|
||||
expect(getHourIn12Hour(dayjs('2024-01-01 23:00'))).toBe(11)
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for getDateWithTimezone
|
||||
describe('getDateWithTimezone', () => {
|
||||
it('should return a cloned date when no timezone is provided', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const result = getDateWithTimezone({ date })
|
||||
|
||||
expect(result.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should return current date clone when neither date nor timezone is provided', () => {
|
||||
const result = getDateWithTimezone({})
|
||||
const now = dayjs()
|
||||
|
||||
expect(result.format('YYYY-MM-DD')).toBe(now.format('YYYY-MM-DD'))
|
||||
})
|
||||
|
||||
it('should apply timezone to provided date', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = getDateWithTimezone({ date, timezone: 'America/New_York' })
|
||||
|
||||
// dayjs.tz converts the date to the given timezone
|
||||
expect(result).toBeDefined()
|
||||
expect(result.isValid()).toBe(true)
|
||||
})
|
||||
|
||||
it('should return current time in timezone when only timezone is provided', () => {
|
||||
const result = getDateWithTimezone({ timezone: 'Asia/Tokyo' })
|
||||
|
||||
expect(result.utcOffset()).toBe(dayjs().tz('Asia/Tokyo').utcOffset())
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for toDayjs additional edge cases
|
||||
describe('toDayjs edge cases', () => {
|
||||
it('should return undefined for empty string', () => {
|
||||
expect(toDayjs('')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined for undefined', () => {
|
||||
expect(toDayjs(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle Dayjs object input', () => {
|
||||
const date = dayjs('2024-06-15')
|
||||
const result = toDayjs(date)
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should handle Dayjs object with timezone', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = toDayjs(date, { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should parse with custom format when format matches common formats', () => {
|
||||
// Uses a format from COMMON_PARSE_FORMATS
|
||||
const result = toDayjs('2024-06-15', { format: 'YYYY-MM-DD' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should fall back when custom format does not match', () => {
|
||||
// dayjs strict mode with format requires customParseFormat plugin
|
||||
// which is not loaded, so invalid format falls through to other parsing
|
||||
const result = toDayjs('2024-06-15', { format: 'INVALID', timezone: 'UTC' })
|
||||
|
||||
// It will still be parsed by fallback mechanisms
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should parse time with seconds', () => {
|
||||
const result = toDayjs('14:30:45', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(14)
|
||||
expect(result?.minute()).toBe(30)
|
||||
expect(result?.second()).toBe(45)
|
||||
})
|
||||
|
||||
it('should parse time with milliseconds', () => {
|
||||
const result = toDayjs('14:30:45.123', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.millisecond()).toBe(123)
|
||||
})
|
||||
|
||||
it('should normalize short milliseconds by padding', () => {
|
||||
const result = toDayjs('14:30:45.1', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.millisecond()).toBe(100)
|
||||
})
|
||||
|
||||
it('should truncate long milliseconds to 3 digits', () => {
|
||||
// The time regex only captures up to 3 digits for ms, so 4+ digit values
|
||||
// don't match the regex and fall through to common format parsing
|
||||
const result = toDayjs('14:30:45.12', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
// 2-digit ms "12" gets padded to "120"
|
||||
expect(result?.millisecond()).toBe(120)
|
||||
})
|
||||
|
||||
it('should parse 12-hour AM time', () => {
|
||||
const result = toDayjs('07:15 AM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(7)
|
||||
expect(result?.minute()).toBe(15)
|
||||
})
|
||||
|
||||
it('should parse 12-hour time with seconds', () => {
|
||||
const result = toDayjs('07:15:30 PM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(19)
|
||||
expect(result?.second()).toBe(30)
|
||||
})
|
||||
|
||||
it('should handle 12 PM correctly', () => {
|
||||
const result = toDayjs('12:00 PM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(12)
|
||||
})
|
||||
|
||||
it('should handle 12 AM correctly', () => {
|
||||
const result = toDayjs('12:00 AM', { timezone: 'UTC' })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.hour()).toBe(0)
|
||||
})
|
||||
|
||||
it('should use custom formats array when provided', () => {
|
||||
const result = toDayjs('2024.06.15', { formats: ['YYYY.MM.DD'] })
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should fall back to native parsing for ISO strings', () => {
|
||||
const result = toDayjs('2024-06-15T12:00:00.000Z')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
})
|
||||
|
||||
it('should return undefined for completely unparseable value', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const result = toDayjs('not-a-date')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for parseDateWithFormat
|
||||
describe('parseDateWithFormat', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(parseDateWithFormat('')).toBeNull()
|
||||
})
|
||||
|
||||
it('should parse with provided format from common formats', () => {
|
||||
// Uses YYYY-MM-DD which is in COMMON_PARSE_FORMATS
|
||||
const result = parseDateWithFormat('2024-06-15', 'YYYY-MM-DD')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should return null for invalid date with format', () => {
|
||||
const result = parseDateWithFormat('not-a-date', 'YYYY-MM-DD')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('should try common formats when no format is specified', () => {
|
||||
const result = parseDateWithFormat('2024-06-15')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should parse ISO datetime format', () => {
|
||||
const result = parseDateWithFormat('2024-06-15T12:00:00')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for unparseable string without format', () => {
|
||||
const result = parseDateWithFormat('gibberish')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for formatDateForOutput
|
||||
describe('formatDateForOutput', () => {
|
||||
it('should return empty string for invalid date', () => {
|
||||
const invalidDate = dayjs('invalid')
|
||||
expect(formatDateForOutput(invalidDate)).toBe('')
|
||||
})
|
||||
|
||||
it('should format date-only output without time', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = formatDateForOutput(date)
|
||||
|
||||
expect(result).toBe('2024-06-15')
|
||||
})
|
||||
|
||||
it('should format with time when includeTime is true', () => {
|
||||
const date = dayjs('2024-06-15T12:00:00')
|
||||
const result = formatDateForOutput(date, true)
|
||||
|
||||
expect(result).toContain('2024-06-15')
|
||||
expect(result).toContain('12:00:00')
|
||||
})
|
||||
|
||||
it('should default to date-only format', () => {
|
||||
const date = dayjs('2024-06-15T14:30:00')
|
||||
const result = formatDateForOutput(date)
|
||||
|
||||
expect(result).toBe('2024-06-15')
|
||||
expect(result).not.toContain('14:30')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -111,7 +111,7 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => {
|
||||
return DEFAULT_OFFSET_STR
|
||||
// Extract offset from name format like "-11:00 Niue Time" or "+05:30 India Time"
|
||||
// Name format is always "{offset}:{minutes} {timezone name}"
|
||||
const offsetMatch = tzItem.name.match(/^([+-]?\d{1,2}):(\d{2})/)
|
||||
const offsetMatch = /^([+-]?\d{1,2}):(\d{2})/.exec(tzItem.name)
|
||||
if (!offsetMatch)
|
||||
return DEFAULT_OFFSET_STR
|
||||
// Parse hours and minutes separately
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { YearAndMonthPickerFooterProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Footer from './footer'
|
||||
|
||||
// Factory for Footer props
|
||||
const createFooterProps = (overrides: Partial<YearAndMonthPickerFooterProps> = {}): YearAndMonthPickerFooterProps => ({
|
||||
handleYearMonthCancel: vi.fn(),
|
||||
handleYearMonthConfirm: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YearAndMonthPicker Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should render Cancel and OK buttons', () => {
|
||||
const props = createFooterProps()
|
||||
render(<Footer {...props} />)
|
||||
|
||||
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/operation\.ok/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call handleYearMonthCancel when Cancel button is clicked', () => {
|
||||
const handleYearMonthCancel = vi.fn()
|
||||
const props = createFooterProps({ handleYearMonthCancel })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.cancel/))
|
||||
|
||||
expect(handleYearMonthCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call handleYearMonthConfirm when OK button is clicked', () => {
|
||||
const handleYearMonthConfirm = vi.fn()
|
||||
const props = createFooterProps({ handleYearMonthConfirm })
|
||||
render(<Footer {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByText(/operation\.ok/))
|
||||
|
||||
expect(handleYearMonthConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { YearAndMonthPickerHeaderProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Header from './header'
|
||||
|
||||
// Factory for Header props
|
||||
const createHeaderProps = (overrides: Partial<YearAndMonthPickerHeaderProps> = {}): YearAndMonthPickerHeaderProps => ({
|
||||
selectedYear: 2024,
|
||||
selectedMonth: 5, // June (0-indexed)
|
||||
onClick: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YearAndMonthPicker Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests
|
||||
describe('Rendering', () => {
|
||||
it('should display the selected year', () => {
|
||||
const props = createHeaderProps({ selectedYear: 2024 })
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.getByText(/2024/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a clickable button', () => {
|
||||
const props = createHeaderProps()
|
||||
render(<Header {...props} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Interaction tests
|
||||
describe('Interactions', () => {
|
||||
it('should call onClick when the header button is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const props = createHeaderProps({ onClick })
|
||||
render(<Header {...props} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { YearAndMonthPickerOptionsProps } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Options from './options'
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
const createOptionsProps = (overrides: Partial<YearAndMonthPickerOptionsProps> = {}): YearAndMonthPickerOptionsProps => ({
|
||||
selectedMonth: 5,
|
||||
selectedYear: 2024,
|
||||
handleMonthSelect: vi.fn(),
|
||||
handleYearSelect: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('YearAndMonthPicker Options', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render month options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const monthItems = screen.getAllByText(/months\./)
|
||||
expect(monthItems).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('should render year options', () => {
|
||||
const props = createOptionsProps()
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const allItems = screen.getAllByRole('listitem')
|
||||
expect(allItems).toHaveLength(212)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should call handleMonthSelect when a month is clicked', () => {
|
||||
const handleMonthSelect = vi.fn()
|
||||
const props = createOptionsProps({ handleMonthSelect })
|
||||
render(<Options {...props} />)
|
||||
// The mock returns 'time.months.January' for the first month
|
||||
fireEvent.click(screen.getByText(/months\.January/))
|
||||
expect(handleMonthSelect).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should call handleYearSelect when a year is clicked', () => {
|
||||
const handleYearSelect = vi.fn()
|
||||
const props = createOptionsProps({ handleYearSelect })
|
||||
|
||||
render(<Options {...props} />)
|
||||
fireEvent.click(screen.getByText('2024'))
|
||||
|
||||
expect(handleYearSelect).toHaveBeenCalledWith(2024)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selection', () => {
|
||||
it('should render selected month in the list', () => {
|
||||
const props = createOptionsProps({ selectedMonth: 0 })
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
const monthItems = screen.getAllByText(/months\./)
|
||||
expect(monthItems.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should render selected year in the list', () => {
|
||||
const props = createOptionsProps({ selectedYear: 2024 })
|
||||
|
||||
render(<Options {...props} />)
|
||||
|
||||
expect(screen.getByText('2024')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ const AnnotationReply = ({
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const matched = pathname.match(/\/app\/([^/]+)/)
|
||||
const matched = /\/app\/([^/]+)/.exec(pathname)
|
||||
const appId = (matched?.length && matched[1]) ? matched[1] : ''
|
||||
const featuresStore = useFeaturesStore()
|
||||
const annotationReply = useFeatures(s => s.features.annotationReply)
|
||||
|
||||
@@ -48,7 +48,7 @@ const FeatureCard = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Switch disabled={disabled} className="shrink-0" onChange={state => onChange?.(state)} defaultValue={value} />
|
||||
<Switch disabled={disabled} className="shrink-0" onChange={state => onChange?.(state)} value={value} />
|
||||
</div>
|
||||
{description && (
|
||||
<div className="system-xs-regular line-clamp-2 min-h-8 text-text-tertiary">{description}</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ const ModerationContent: FC<ModerationContentProps> = ({
|
||||
}
|
||||
<Switch
|
||||
size="l"
|
||||
defaultValue={config.enabled}
|
||||
value={config.enabled}
|
||||
onChange={v => handleConfigChange('enabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ const VoiceParamConfig = ({
|
||||
}: VoiceParamConfigProps) => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
const matched = pathname.match(/\/app\/([^/]+)/)
|
||||
const matched = /\/app\/([^/]+)/.exec(pathname)
|
||||
const appId = (matched?.length && matched[1]) ? matched[1] : ''
|
||||
const text2speech = useFeatures(state => state.features.text2speech)
|
||||
const featuresStore = useFeaturesStore()
|
||||
@@ -232,7 +232,7 @@ const VoiceParamConfig = ({
|
||||
</div>
|
||||
<Switch
|
||||
className="shrink-0"
|
||||
defaultValue={text2speech?.autoPlay === TtsAutoPlay.enabled}
|
||||
value={text2speech?.autoPlay === TtsAutoPlay.enabled}
|
||||
onChange={(value: boolean) => {
|
||||
handleChange({
|
||||
autoPlay: value ? TtsAutoPlay.enabled : TtsAutoPlay.disabled,
|
||||
|
||||
@@ -21,7 +21,7 @@ export type IGAProps = {
|
||||
const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
|
||||
if (!cspHeader)
|
||||
return undefined
|
||||
const nonceMatch = cspHeader.match(/'nonce-([^']+)'/)
|
||||
const nonceMatch = /'nonce-([^']+)'/.exec(cspHeader)
|
||||
return nonceMatch ? nonceMatch[1] : undefined
|
||||
}
|
||||
|
||||
|
||||
299
web/app/components/base/mermaid/index.spec.tsx
Normal file
299
web/app/components/base/mermaid/index.spec.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import mermaid from 'mermaid'
|
||||
import Flowchart from './index'
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' }),
|
||||
mermaidAPI: {
|
||||
render: vi.fn().mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg-api</svg>', diagramType: 'flowchart' }),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./utils', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>
|
||||
return {
|
||||
...actual,
|
||||
svgToBase64: vi.fn().mockResolvedValue('data:image/svg+xml;base64,dGVzdC1zdmc='),
|
||||
waitForDOMElement: vi.fn((cb: () => Promise<unknown>) => cb()),
|
||||
}
|
||||
})
|
||||
|
||||
describe('Mermaid Flowchart Component', () => {
|
||||
const mockCode = 'graph TD\n A-->B'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(mermaid.initialize).mockImplementation(() => { })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should initialize mermaid on mount', async () => {
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={mockCode} />)
|
||||
})
|
||||
expect(mermaid.initialize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render mermaid chart after debounce', async () => {
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={mockCode} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should render gantt charts with specific formatting', async () => {
|
||||
const ganttCode = 'gantt\ntitle T\nTask :after task1, after task2'
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={ganttCode} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should render mindmap and sequenceDiagram charts', async () => {
|
||||
const mindmapCode = 'mindmap\n root\n topic1'
|
||||
const { unmount } = await act(async () => {
|
||||
return render(<Flowchart PrimitiveCode={mindmapCode} />)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
|
||||
unmount()
|
||||
|
||||
const sequenceCode = 'sequenceDiagram\n A->>B: Hello'
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={sequenceCode} />)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should handle dark theme configuration', async () => {
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={mockCode} theme="dark" />)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should switch between classic and handDrawn looks', async () => {
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={mockCode} />)
|
||||
})
|
||||
|
||||
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
|
||||
|
||||
const handDrawnBtn = screen.getByText(/handDrawn/i)
|
||||
await act(async () => {
|
||||
fireEvent.click(handDrawnBtn)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg-api')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
|
||||
const classicBtn = screen.getByText(/classic/i)
|
||||
await act(async () => {
|
||||
fireEvent.click(classicBtn)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-svg')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should toggle theme manually', async () => {
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={mockCode} theme="light" />)
|
||||
})
|
||||
|
||||
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
|
||||
|
||||
const toggleBtn = screen.getByRole('button')
|
||||
await act(async () => {
|
||||
fireEvent.click(toggleBtn)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mermaid.initialize).toHaveBeenCalled()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should open image preview when clicking the chart', async () => {
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={mockCode} />)
|
||||
})
|
||||
|
||||
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
|
||||
|
||||
const chartDiv = screen.getByText('test-svg').closest('.mermaid')
|
||||
await act(async () => {
|
||||
fireEvent.click(chartDiv!)
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('.image-preview-container')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render when code is too short', async () => {
|
||||
const shortCode = 'graph'
|
||||
vi.useFakeTimers()
|
||||
render(<Flowchart PrimitiveCode={shortCode} />)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(mermaid.render).not.toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle rendering errors gracefully', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
const errorMsg = 'Syntax error'
|
||||
vi.mocked(mermaid.render).mockRejectedValue(new Error(errorMsg))
|
||||
|
||||
// Use unique code to avoid hitting the module-level diagramCache from previous tests
|
||||
const uniqueCode = 'graph TD\n X-->Y\n Y-->Z'
|
||||
const { container } = render(<Flowchart PrimitiveCode={uniqueCode} />)
|
||||
|
||||
await waitFor(() => {
|
||||
const errorSpan = container.querySelector('.text-red-500 span.ml-2')
|
||||
expect(errorSpan).toBeInTheDocument()
|
||||
expect(errorSpan?.textContent).toContain('Rendering failed')
|
||||
}, { timeout: 5000 })
|
||||
consoleSpy.mockRestore()
|
||||
// Restore default mock to prevent leaking into subsequent tests
|
||||
vi.mocked(mermaid.render).mockResolvedValue({ svg: '<svg id="mermaid-chart">test-svg</svg>', diagramType: 'flowchart' })
|
||||
}, 10000)
|
||||
|
||||
it('should use cached diagram if available', async () => {
|
||||
const { rerender } = render(<Flowchart PrimitiveCode={mockCode} />)
|
||||
|
||||
await waitFor(() => screen.getByText('test-svg'), { timeout: 3000 })
|
||||
|
||||
vi.mocked(mermaid.render).mockClear()
|
||||
|
||||
await act(async () => {
|
||||
rerender(<Flowchart PrimitiveCode={mockCode} />)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
})
|
||||
expect(mermaid.render).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle invalid mermaid code completion', async () => {
|
||||
const invalidCode = 'graph TD\nA -->' // Incomplete
|
||||
await act(async () => {
|
||||
render(<Flowchart PrimitiveCode={invalidCode} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Diagram code is not complete or invalid.')).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should handle unmount cleanup', async () => {
|
||||
const { unmount } = render(<Flowchart PrimitiveCode={mockCode} />)
|
||||
await act(async () => {
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mermaid Flowchart Component Module Isolation', () => {
|
||||
const mockCode = 'graph TD\n A-->B'
|
||||
|
||||
let mermaidFresh: typeof mermaid
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
const mod = await import('mermaid') as unknown as { default: typeof mermaid } | typeof mermaid
|
||||
mermaidFresh = 'default' in mod ? mod.default : mod
|
||||
vi.mocked(mermaidFresh.initialize).mockImplementation(() => { })
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle initialization failure', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
const { default: FlowchartFresh } = await import('./index')
|
||||
|
||||
vi.mocked(mermaidFresh.initialize).mockImplementationOnce(() => {
|
||||
throw new Error('Init fail')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error))
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle mermaidAPI missing fallback', async () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
const originalMermaidAPI = mermaidFresh.mermaidAPI
|
||||
// @ts-expect-error need to set undefined for testing
|
||||
mermaidFresh.mermaidAPI = undefined
|
||||
|
||||
const { default: FlowchartFresh } = await import('./index')
|
||||
|
||||
const { container } = render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
|
||||
// Wait for initial render to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/handDrawn/)).toBeInTheDocument()
|
||||
}, { timeout: 3000 })
|
||||
|
||||
const handDrawnBtn = screen.getByText(/handDrawn/)
|
||||
await act(async () => {
|
||||
fireEvent.click(handDrawnBtn)
|
||||
})
|
||||
|
||||
// When mermaidAPI is undefined, handDrawn style falls back to mermaid.render.
|
||||
// The module captures mermaidAPI at import time, so setting it to undefined on
|
||||
// the mocked object may not affect the module's internal reference.
|
||||
// Verify that the rendering completes (either with svg or error)
|
||||
await waitFor(() => {
|
||||
const hasSvg = container.querySelector('.mermaid div')
|
||||
const hasError = container.querySelector('.text-red-500')
|
||||
expect(hasSvg || hasError).toBeTruthy()
|
||||
}, { timeout: 5000 })
|
||||
|
||||
mermaidFresh.mermaidAPI = originalMermaidAPI
|
||||
consoleSpy.mockRestore()
|
||||
}, 10000)
|
||||
|
||||
it('should handle configuration failure', async () => {
|
||||
vi.mocked(mermaidFresh.initialize).mockImplementation(() => {
|
||||
throw new Error('Config fail')
|
||||
})
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
const { default: FlowchartFresh } = await import('./index')
|
||||
|
||||
await act(async () => {
|
||||
render(<FlowchartFresh PrimitiveCode={mockCode} />)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Mermaid initialization error:', expect.any(Error))
|
||||
})
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -239,7 +239,7 @@ const Flowchart = (props: FlowchartProps) => {
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
// Gantt charts have specific syntax needs.
|
||||
const taskMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)/)
|
||||
const taskMatch = /^\s*([^:]+?)\s*:\s*(.*)/.exec(line)
|
||||
if (!taskMatch)
|
||||
return line // Not a task line, return as is.
|
||||
|
||||
|
||||
@@ -1,59 +1,265 @@
|
||||
import { cleanUpSvgCode, prepareMermaidCode, sanitizeMermaidCode } from './utils'
|
||||
import { cleanUpSvgCode, isMermaidCodeComplete, prepareMermaidCode, processSvgForTheme, sanitizeMermaidCode, svgToBase64, waitForDOMElement } from './utils'
|
||||
|
||||
describe('cleanUpSvgCode', () => {
|
||||
it('replaces old-style <br> tags with the new style', () => {
|
||||
it('should replace old-style <br> tags with self-closing <br/>', () => {
|
||||
const result = cleanUpSvgCode('<br>test<br>')
|
||||
expect(result).toEqual('<br/>test<br/>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeMermaidCode', () => {
|
||||
it('removes click directives to prevent link/callback injection', () => {
|
||||
const unsafeProtocol = ['java', 'script:'].join('')
|
||||
const input = [
|
||||
'gantt',
|
||||
'title Demo',
|
||||
'section S1',
|
||||
'Task 1 :a1, 2020-01-01, 1d',
|
||||
`click A href "${unsafeProtocol}alert(location.href)"`,
|
||||
'click B call callback()',
|
||||
].join('\n')
|
||||
|
||||
const result = sanitizeMermaidCode(input)
|
||||
|
||||
expect(result).toContain('gantt')
|
||||
expect(result).toContain('Task 1')
|
||||
expect(result).not.toContain('click A')
|
||||
expect(result).not.toContain('click B')
|
||||
expect(result).not.toContain(unsafeProtocol)
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null/non-string input', () => {
|
||||
// @ts-expect-error need to test null input
|
||||
expect(sanitizeMermaidCode(null)).toBe('')
|
||||
// @ts-expect-error need to test undefined input
|
||||
expect(sanitizeMermaidCode(undefined)).toBe('')
|
||||
// @ts-expect-error need to test non-string input
|
||||
expect(sanitizeMermaidCode(123)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('removes Mermaid init directives to prevent config overrides', () => {
|
||||
const input = [
|
||||
'%%{init: {"securityLevel":"loose"}}%%',
|
||||
'graph TD',
|
||||
'A-->B',
|
||||
].join('\n')
|
||||
describe('Security', () => {
|
||||
it('should remove click directives to prevent link/callback injection', () => {
|
||||
const unsafeProtocol = ['java', 'script:'].join('')
|
||||
const input = [
|
||||
'gantt',
|
||||
'title Demo',
|
||||
'section S1',
|
||||
'Task 1 :a1, 2020-01-01, 1d',
|
||||
`click A href "${unsafeProtocol}alert(location.href)"`,
|
||||
'click B call callback()',
|
||||
].join('\n')
|
||||
|
||||
const result = sanitizeMermaidCode(input)
|
||||
const result = sanitizeMermaidCode(input)
|
||||
|
||||
expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
|
||||
expect(result).toContain('gantt')
|
||||
expect(result).toContain('Task 1')
|
||||
expect(result).not.toContain('click A')
|
||||
expect(result).not.toContain('click B')
|
||||
expect(result).not.toContain(unsafeProtocol)
|
||||
})
|
||||
|
||||
it('should remove Mermaid init directives to prevent config overrides', () => {
|
||||
const input = [
|
||||
'%%{init: {"securityLevel":"loose"}}%%',
|
||||
'graph TD',
|
||||
'A-->B',
|
||||
].join('\n')
|
||||
|
||||
const result = sanitizeMermaidCode(input)
|
||||
|
||||
expect(result).toEqual(['graph TD', 'A-->B'].join('\n'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareMermaidCode', () => {
|
||||
it('sanitizes click directives in flowcharts', () => {
|
||||
const unsafeProtocol = ['java', 'script:'].join('')
|
||||
const input = [
|
||||
'graph TD',
|
||||
'A[Click]-->B',
|
||||
`click A href "${unsafeProtocol}alert(1)"`,
|
||||
].join('\n')
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null/non-string input', () => {
|
||||
// @ts-expect-error need to test null input
|
||||
expect(prepareMermaidCode(null, 'classic')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
const result = prepareMermaidCode(input, 'classic')
|
||||
describe('Sanitization', () => {
|
||||
it('should sanitize click directives in flowcharts', () => {
|
||||
const unsafeProtocol = ['java', 'script:'].join('')
|
||||
const input = [
|
||||
'graph TD',
|
||||
'A[Click]-->B',
|
||||
`click A href "${unsafeProtocol}alert(1)"`,
|
||||
].join('\n')
|
||||
|
||||
expect(result).toContain('graph TD')
|
||||
expect(result).not.toContain('click ')
|
||||
expect(result).not.toContain(unsafeProtocol)
|
||||
const result = prepareMermaidCode(input, 'classic')
|
||||
|
||||
expect(result).toContain('graph TD')
|
||||
expect(result).not.toContain('click ')
|
||||
expect(result).not.toContain(unsafeProtocol)
|
||||
})
|
||||
|
||||
it('should replace <br> with newline', () => {
|
||||
const input = 'graph TD\nA[Node<br>Line]-->B'
|
||||
const result = prepareMermaidCode(input, 'classic')
|
||||
expect(result).toContain('Node\nLine')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HandDrawn Style', () => {
|
||||
it('should handle handDrawn style specifically', () => {
|
||||
const input = 'flowchart TD\nstyle A fill:#fff\nlinkStyle 0 stroke:#000\nA-->B'
|
||||
const result = prepareMermaidCode(input, 'handDrawn')
|
||||
expect(result).toContain('graph TD')
|
||||
expect(result).not.toContain('style ')
|
||||
expect(result).not.toContain('linkStyle ')
|
||||
expect(result).toContain('A-->B')
|
||||
})
|
||||
|
||||
it('should add TD fallback for handDrawn if missing', () => {
|
||||
const input = 'A-->B'
|
||||
const result = prepareMermaidCode(input, 'handDrawn')
|
||||
expect(result).toBe('graph TD\nA-->B')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('svgToBase64', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should return empty string for empty input', async () => {
|
||||
expect(await svgToBase64('')).toBe('')
|
||||
})
|
||||
|
||||
it('should convert svg to base64', async () => {
|
||||
const svg = '<svg>test</svg>'
|
||||
const result = await svgToBase64(svg)
|
||||
expect(result).toContain('base64,')
|
||||
expect(result).toContain('image/svg+xml')
|
||||
})
|
||||
|
||||
it('should convert svg with xml declaration to base64', async () => {
|
||||
const svg = '<?xml version="1.0" encoding="UTF-8"?><svg>test</svg>'
|
||||
const result = await svgToBase64(svg)
|
||||
expect(result).toContain('base64,')
|
||||
expect(result).toContain('image/svg+xml')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle errors gracefully', async () => {
|
||||
const encoderSpy = vi.spyOn(globalThis, 'TextEncoder').mockImplementation(() => ({
|
||||
encoding: 'utf-8',
|
||||
encode: () => { throw new Error('Encoder fail') },
|
||||
encodeInto: () => ({ read: 0, written: 0 }),
|
||||
} as unknown as TextEncoder))
|
||||
|
||||
const result = await svgToBase64('<svg>fail</svg>')
|
||||
expect(result).toBe('')
|
||||
|
||||
encoderSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('processSvgForTheme', () => {
|
||||
const themes = {
|
||||
light: {
|
||||
nodeColors: [{ bg: '#fefefe' }, { bg: '#eeeeee' }],
|
||||
connectionColor: '#cccccc',
|
||||
},
|
||||
dark: {
|
||||
nodeColors: [{ bg: '#121212' }, { bg: '#222222' }],
|
||||
connectionColor: '#333333',
|
||||
},
|
||||
}
|
||||
|
||||
describe('Light Theme', () => {
|
||||
it('should process light theme node colors', () => {
|
||||
const svg = '<rect fill="#ffffff" class="node-1"/>'
|
||||
const result = processSvgForTheme(svg, false, false, themes)
|
||||
expect(result).toContain('fill="#fefefe"')
|
||||
})
|
||||
|
||||
it('should process handDrawn style for light theme', () => {
|
||||
const svg = '<path fill="#ffffff" stroke="#ffffff"/>'
|
||||
const result = processSvgForTheme(svg, false, true, themes)
|
||||
expect(result).toContain('fill="#fefefe"')
|
||||
expect(result).toContain('stroke="#cccccc"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dark Theme', () => {
|
||||
it('should process dark theme node colors and general elements', () => {
|
||||
const svg = '<rect fill="#ffffff" class="node-1"/><path stroke="#ffffff"/><rect fill="#ffffff" style="fill: #000000; stroke: #000000"/>'
|
||||
const result = processSvgForTheme(svg, true, false, themes)
|
||||
expect(result).toContain('fill="#121212"')
|
||||
expect(result).toContain('fill="#1e293b"') // Generic rect replacement
|
||||
expect(result).toContain('stroke="#333333"')
|
||||
})
|
||||
|
||||
it('should handle multiple node colors in cyclic manner', () => {
|
||||
const svg = '<rect fill="#ffffff" class="node-1"/><rect fill="#ffffff" class="node-2"/><rect fill="#ffffff" class="node-3"/>'
|
||||
const result = processSvgForTheme(svg, true, false, themes)
|
||||
const fillMatches = result.match(/fill="#[a-fA-F0-9]{6}"/g)
|
||||
expect(fillMatches).toContain('fill="#121212"')
|
||||
expect(fillMatches).toContain('fill="#222222"')
|
||||
expect(fillMatches?.filter(f => f === 'fill="#121212"').length).toBe(2)
|
||||
})
|
||||
|
||||
it('should process handDrawn style for dark theme', () => {
|
||||
const svg = '<path fill="#ffffff" stroke="#ffffff"/>'
|
||||
const result = processSvgForTheme(svg, true, true, themes)
|
||||
expect(result).toContain('fill="#121212"')
|
||||
expect(result).toContain('stroke="#333333"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMermaidCodeComplete', () => {
|
||||
describe('Edge Cases', () => {
|
||||
it('should return false for empty input', () => {
|
||||
expect(isMermaidCodeComplete('')).toBe(false)
|
||||
expect(isMermaidCodeComplete(' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('should detect common syntax errors', () => {
|
||||
expect(isMermaidCodeComplete('graph TD\nA--> undefined')).toBe(false)
|
||||
expect(isMermaidCodeComplete('graph TD\nA--> [object Object]')).toBe(false)
|
||||
expect(isMermaidCodeComplete('graph TD\nA-->')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle validation error gracefully', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
const startsWithSpy = vi.spyOn(String.prototype, 'startsWith').mockImplementation(() => {
|
||||
throw new Error('Start fail')
|
||||
})
|
||||
|
||||
expect(isMermaidCodeComplete('graph TD')).toBe(false)
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Mermaid code validation error:', expect.any(Error))
|
||||
|
||||
startsWithSpy.mockRestore()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Chart Types', () => {
|
||||
it('should validate gantt charts', () => {
|
||||
expect(isMermaidCodeComplete('gantt\ntitle T\nsection S\nTask')).toBe(true)
|
||||
expect(isMermaidCodeComplete('gantt\ntitle T')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate mindmaps', () => {
|
||||
expect(isMermaidCodeComplete('mindmap\nroot')).toBe(true)
|
||||
expect(isMermaidCodeComplete('mindmap')).toBe(false)
|
||||
})
|
||||
|
||||
it('should validate other chart types', () => {
|
||||
expect(isMermaidCodeComplete('graph TD\nA-->B')).toBe(true)
|
||||
expect(isMermaidCodeComplete('pie title P\n"A": 10')).toBe(true)
|
||||
expect(isMermaidCodeComplete('invalid chart')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('waitForDOMElement', () => {
|
||||
it('should resolve when callback resolves', async () => {
|
||||
const cb = vi.fn().mockResolvedValue('success')
|
||||
const result = await waitForDOMElement(cb)
|
||||
expect(result).toBe('success')
|
||||
expect(cb).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should retry on failure', async () => {
|
||||
const cb = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockResolvedValue('success')
|
||||
const result = await waitForDOMElement(cb, 3, 10)
|
||||
expect(result).toBe('success')
|
||||
expect(cb).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should reject after max attempts', async () => {
|
||||
const cb = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
await expect(waitForDOMElement(cb, 2, 10)).rejects.toThrow('fail')
|
||||
expect(cb).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,7 +185,7 @@ export function isMermaidCodeComplete(code: string): boolean {
|
||||
const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
|
||||
&& !trimmedCode.includes('[object Object]')
|
||||
&& trimmedCode.split('\n').every(line =>
|
||||
!(line.includes('-->') && !line.match(/\S+\s*-->\s*\S+/)))
|
||||
!(line.includes('-->') && !/\S+\s*-->\s*\S+/.exec(line)))
|
||||
|
||||
return hasValidStart && isBalanced && hasNoSyntaxErrors
|
||||
}
|
||||
|
||||
104
web/app/components/base/message-log-modal/index.spec.tsx
Normal file
104
web/app/components/base/message-log-modal/index.spec.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
import MessageLogModal from './index'
|
||||
|
||||
let clickAwayHandler: (() => void) | null = null
|
||||
vi.mock('ahooks', () => ({
|
||||
useClickAway: (fn: () => void) => {
|
||||
clickAwayHandler = fn
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/run', () => ({
|
||||
default: ({ activeTab, runDetailUrl, tracingListUrl }: { activeTab: string, runDetailUrl: string, tracingListUrl: string }) => (
|
||||
<div
|
||||
data-testid="workflow-run"
|
||||
data-active-tab={activeTab}
|
||||
data-run-detail-url={runDetailUrl}
|
||||
data-tracing-list-url={tracingListUrl}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const mockLog = {
|
||||
id: 'msg-1',
|
||||
content: 'mock log message',
|
||||
workflow_run_id: 'run-1',
|
||||
isAnswer: true,
|
||||
}
|
||||
|
||||
describe('MessageLogModal', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
clickAwayHandler = null
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
vi.mocked(useStore).mockImplementation((selector: any) => selector({
|
||||
appDetail: { id: 'app-1' },
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders nothing if currentLogItem is missing', () => {
|
||||
const { container } = render(<MessageLogModal width={800} onCancel={onCancel} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing if currentLogItem.workflow_run_id is missing', () => {
|
||||
const { container } = render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={{ id: '1' } as IChatItem} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders modal with correct title and Run component', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
|
||||
expect(screen.getByText(/title/i)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-run')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('passes correct props to Run component', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} defaultTab="TRACING" />)
|
||||
const runComponent = screen.getByTestId('workflow-run')
|
||||
expect(runComponent.getAttribute('data-active-tab')).toBe('TRACING')
|
||||
expect(runComponent.getAttribute('data-run-detail-url')).toBe('/apps/app-1/workflow-runs/run-1')
|
||||
expect(runComponent.getAttribute('data-tracing-list-url')).toBe('/apps/app-1/workflow-runs/run-1/node-executions')
|
||||
})
|
||||
|
||||
it('sets fixed style when fixedWidth is false (floating)', () => {
|
||||
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={false} />)
|
||||
const modal = container.firstChild as HTMLElement
|
||||
expect(modal.style.position).toBe('fixed')
|
||||
expect(modal.style.width).toBe('480px')
|
||||
})
|
||||
|
||||
it('sets fixed width when fixedWidth is true', () => {
|
||||
const { container } = render(<MessageLogModal width={1000} onCancel={onCancel} currentLogItem={mockLog} fixedWidth={true} />)
|
||||
const modal = container.firstChild as HTMLElement
|
||||
expect(modal.style.width).toBe('1000px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('calls onCancel when close icon is clicked', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
|
||||
const closeButton = screen.getByTestId('close-button')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
fireEvent.click(closeButton)
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when clicked away', () => {
|
||||
render(<MessageLogModal width={800} onCancel={onCancel} currentLogItem={mockLog} />)
|
||||
expect(clickAwayHandler).toBeTruthy()
|
||||
clickAwayHandler!()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -57,8 +57,8 @@ const MessageLogModal: FC<MessageLogModalProps> = ({
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<h1 className="system-xl-semibold shrink-0 px-4 py-1 text-text-primary">{t('runDetail.title', { ns: 'appLog' })}</h1>
|
||||
<span className="absolute right-3 top-4 z-20 cursor-pointer p-1" onClick={onCancel}>
|
||||
<h1 className="shrink-0 px-4 py-1 text-text-primary system-xl-semibold">{t('runDetail.title', { ns: 'appLog' })}</h1>
|
||||
<span className="absolute right-3 top-4 z-20 cursor-pointer p-1" onClick={onCancel} data-testid="close-button">
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</span>
|
||||
<Run
|
||||
|
||||
84
web/app/components/base/modal-like-wrap/index.spec.tsx
Normal file
84
web/app/components/base/modal-like-wrap/index.spec.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import ModalLikeWrap from '.'
|
||||
|
||||
describe('ModalLikeWrap', () => {
|
||||
const defaultProps = {
|
||||
title: 'Test Title',
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
children: <div>Test Content</div>,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders title and content correctly', () => {
|
||||
render(<ModalLikeWrap {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Test Title')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders beforeHeader if provided', () => {
|
||||
const beforeHeader = <div data-testid="before-header">Before Header</div>
|
||||
render(<ModalLikeWrap {...defaultProps} beforeHeader={beforeHeader} />)
|
||||
|
||||
expect(screen.getByTestId('before-header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Before Header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('calls onClose when close icon is clicked', async () => {
|
||||
render(<ModalLikeWrap {...defaultProps} />)
|
||||
|
||||
const closeBtn = screen.getByTestId('modal-close-btn')
|
||||
expect(closeBtn).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(closeBtn)
|
||||
})
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel button is clicked', async () => {
|
||||
render(<ModalLikeWrap {...defaultProps} />)
|
||||
|
||||
const cancelBtn = screen.getByText('common.operation.cancel')
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelBtn)
|
||||
})
|
||||
|
||||
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onConfirm when Save button is clicked', async () => {
|
||||
render(<ModalLikeWrap {...defaultProps} />)
|
||||
|
||||
const saveBtn = screen.getByText('common.operation.save')
|
||||
await act(async () => {
|
||||
fireEvent.click(saveBtn)
|
||||
})
|
||||
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('hides close icon when hideCloseBtn is true', () => {
|
||||
render(<ModalLikeWrap {...defaultProps} hideCloseBtn={true} />)
|
||||
|
||||
const closeBtn = document.querySelector('.remixicon')
|
||||
expect(closeBtn).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<ModalLikeWrap {...defaultProps} className="custom-class" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -31,13 +30,13 @@ const ModalLikeWrap: FC<Props> = ({
|
||||
<div className={cn('w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-3 pb-4 pt-3.5 shadow-xl', className)}>
|
||||
{beforeHeader || null}
|
||||
<div className="mb-1 flex h-6 items-center justify-between">
|
||||
<div className="system-xl-semibold text-text-primary">{title}</div>
|
||||
<div className="text-text-primary system-xl-semibold">{title}</div>
|
||||
{!hideCloseBtn && (
|
||||
<div
|
||||
className="cursor-pointer p-1.5 text-text-tertiary"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
<span className="i-ri-close-line size-4" data-testid="modal-close-btn" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
185
web/app/components/base/modal/index.spec.tsx
Normal file
185
web/app/components/base/modal/index.spec.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Modal from '.'
|
||||
|
||||
describe('Modal', () => {
|
||||
describe('Render', () => {
|
||||
it('should not render content when isShow is false', () => {
|
||||
render(
|
||||
<Modal isShow={false} title="Test Modal">
|
||||
<div>Modal Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Test Modal')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Modal Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when isShow is true', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal">
|
||||
<div>Modal Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Test Modal')).toBeInTheDocument()
|
||||
expect(screen.getByText('Modal Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render description when provided', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal" description="Test Description">
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
expect(screen.getByText('Test Description')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should call onClose when close button is clicked', async () => {
|
||||
const handleClose = vi.fn()
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal" closable={true} onClose={handleClose}>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
const closeButton = screen.getByTestId('modal-close-button')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
fireEvent.click(closeButton!)
|
||||
})
|
||||
expect(handleClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should prevent propagation when clicking the scrollable container', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal">
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
const wrapper = document.querySelector('.overflow-y-auto')
|
||||
expect(wrapper).toBeInTheDocument()
|
||||
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
const stopPropagationSpy = vi.spyOn(event, 'stopPropagation')
|
||||
const preventDefaultSpy = vi.spyOn(event, 'preventDefault')
|
||||
|
||||
await act(async () => {
|
||||
wrapper!.dispatchEvent(event)
|
||||
})
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
expect(preventDefaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle clickOutsideNotClose prop', async () => {
|
||||
const handleClose = vi.fn()
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal" clickOutsideNotClose={true} onClose={handleClose}>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' })
|
||||
})
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className to the panel', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal" className="custom-panel-class">
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
const panel = screen.getByText('Test Modal').parentElement
|
||||
expect(panel).toHaveClass('custom-panel-class')
|
||||
})
|
||||
|
||||
it('should apply wrapperClassName and containerClassName', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal
|
||||
isShow={true}
|
||||
title="Test Modal"
|
||||
wrapperClassName="custom-wrapper"
|
||||
containerClassName="custom-container"
|
||||
>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
const dialog = document.querySelector('.custom-wrapper')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
const container = document.querySelector('.custom-container')
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply highPriority z-index when highPriority is true', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal" highPriority={true}>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
const dialog = document.querySelector('.z-\\[1100\\]')
|
||||
expect(dialog).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply overlayOpacity background when overlayOpacity is true', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<Modal isShow={true} title="Test Modal" overlayOpacity={true}>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
|
||||
const overlay = document.querySelector('.bg-workflow-canvas-canvas-overlay')
|
||||
expect(overlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle overflow-visible class based on overflowVisible prop', async () => {
|
||||
const { rerender } = render(
|
||||
<Modal isShow={true} title="Test Modal" overflowVisible={true}>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
|
||||
let panel = screen.getByText('Test Modal').parentElement
|
||||
expect(panel).toHaveClass('overflow-visible')
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
<Modal isShow={true} title="Test Modal" overflowVisible={false}>
|
||||
<div>Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
})
|
||||
panel = screen.getByText('Test Modal').parentElement
|
||||
expect(panel).toHaveClass('overflow-hidden')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Fragment } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -55,27 +54,28 @@ export default function Modal({
|
||||
{!!title && (
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="title-2xl-semi-bold text-text-primary"
|
||||
className="text-text-primary title-2xl-semi-bold"
|
||||
>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
)}
|
||||
{!!description && (
|
||||
<div className="body-md-regular mt-2 text-text-secondary">
|
||||
<div className="mt-2 text-text-secondary body-md-regular">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{closable
|
||||
&& (
|
||||
<div className="absolute right-6 top-6 z-10 flex h-5 w-5 items-center justify-center rounded-2xl hover:cursor-pointer hover:bg-state-base-hover">
|
||||
<RiCloseLine
|
||||
className="h-4 w-4 text-text-tertiary"
|
||||
<span
|
||||
className="i-ri-close-line h-4 w-4 text-text-tertiary"
|
||||
onClick={
|
||||
(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
data-testid="modal-close-button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
114
web/app/components/base/modal/modal.spec.tsx
Normal file
114
web/app/components/base/modal/modal.spec.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Modal from './modal'
|
||||
|
||||
describe('Modal Component', () => {
|
||||
const defaultProps = {
|
||||
title: 'Test Modal',
|
||||
onClose: vi.fn(),
|
||||
onConfirm: vi.fn(),
|
||||
onCancel: vi.fn(),
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders correctly with title and children', () => {
|
||||
render(
|
||||
<Modal {...defaultProps}>
|
||||
<div data-testid="modal-child">Child Content</div>
|
||||
</Modal>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test Modal')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('modal-child')).toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/save/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders subTitle when provided', () => {
|
||||
render(<Modal {...defaultProps} subTitle="Test Subtitle" />)
|
||||
expect(screen.getByText('Test Subtitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders and handles extra button', () => {
|
||||
const onExtraClick = vi.fn()
|
||||
render(
|
||||
<Modal
|
||||
{...defaultProps}
|
||||
showExtraButton={true}
|
||||
extraButtonText="Extra Action"
|
||||
onExtraButtonClick={onExtraClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
const extraBtn = screen.getByText('Extra Action')
|
||||
expect(extraBtn).toBeInTheDocument()
|
||||
fireEvent.click(extraBtn)
|
||||
expect(onExtraClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders footerSlot and bottomSlot', () => {
|
||||
render(
|
||||
<Modal
|
||||
{...defaultProps}
|
||||
footerSlot={<div data-testid="footer-slot">Footer</div>}
|
||||
bottomSlot={<div data-testid="bottom-slot">Bottom</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('footer-slot')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('bottom-slot')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('calls onClose when close icon is clicked', () => {
|
||||
render(<Modal {...defaultProps} />)
|
||||
const closeIcon = screen.getByTestId('close-icon').parentElement
|
||||
fireEvent.click(closeIcon!)
|
||||
expect(defaultProps.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onConfirm when confirm button is clicked', () => {
|
||||
render(<Modal {...defaultProps} confirmButtonText="Confirm Me" />)
|
||||
fireEvent.click(screen.getByText(/confirm/i))
|
||||
expect(defaultProps.onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button is clicked', () => {
|
||||
render(<Modal {...defaultProps} cancelButtonText="Cancel Me" />)
|
||||
fireEvent.click(screen.getByText('Cancel Me'))
|
||||
expect(defaultProps.onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('handles clickOutsideNotClose logic', () => {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('tooltip'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
onClose.mockClear()
|
||||
rerender(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={true} />)
|
||||
fireEvent.click(screen.getByRole('tooltip'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents propagation on internal container click', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<Modal {...defaultProps} onClose={onClose} clickOutsideNotClose={false} />)
|
||||
fireEvent.click(screen.getByText('Test Modal'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('disables buttons when disabled prop is true', () => {
|
||||
render(<Modal {...defaultProps} disabled={true} />)
|
||||
expect(screen.getByText(/cancel/i).closest('button')).toBeDisabled()
|
||||
expect(screen.getByText(/save/i).closest('button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -69,11 +68,11 @@ const Modal = ({
|
||||
)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="title-2xl-semi-bold relative shrink-0 p-6 pb-3 pr-14 text-text-primary">
|
||||
<div className="relative shrink-0 p-6 pb-3 pr-14 text-text-primary title-2xl-semi-bold">
|
||||
{title}
|
||||
{
|
||||
subTitle && (
|
||||
<div className="system-xs-regular mt-1 text-text-tertiary">
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">
|
||||
{subTitle}
|
||||
</div>
|
||||
)
|
||||
@@ -82,7 +81,7 @@ const Modal = ({
|
||||
className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
|
||||
<span className="i-ri-close-line h-5 w-5 text-text-tertiary" data-testid="close-icon" />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
<Switch
|
||||
size="md"
|
||||
className="mr-2"
|
||||
defaultValue={enable}
|
||||
value={enable}
|
||||
onChange={async (val) => {
|
||||
onSwitchChange?.(id, val)
|
||||
}}
|
||||
|
||||
238
web/app/components/base/popover/index.spec.tsx
Normal file
238
web/app/components/base/popover/index.spec.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CustomPopover from '.'
|
||||
|
||||
const CloseButtonContent = ({ onClick }: { onClick?: () => void }) => (
|
||||
<button data-testid="content" onClick={onClick}>Close Me</button>
|
||||
)
|
||||
|
||||
describe('CustomPopover', () => {
|
||||
const defaultProps = {
|
||||
btnElement: <span data-testid="trigger">Trigger</span>,
|
||||
htmlContent: <div data-testid="content">Popover Content</div>,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (vi.isFakeTimers?.())
|
||||
vi.clearAllTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the trigger element', () => {
|
||||
render(<CustomPopover {...defaultProps} />)
|
||||
expect(screen.getByTestId('trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render string as htmlContent', async () => {
|
||||
render(<CustomPopover {...defaultProps} htmlContent="String Content" trigger="click" />)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
})
|
||||
expect(screen.getByText('String Content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should toggle when clicking the button', async () => {
|
||||
vi.useRealTimers()
|
||||
const user = userEvent.setup()
|
||||
render(<CustomPopover {...defaultProps} trigger="click" />)
|
||||
const trigger = screen.getByTestId('trigger')
|
||||
|
||||
await user.click(trigger)
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should open on hover when trigger is "hover" (default)', async () => {
|
||||
render(<CustomPopover {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
|
||||
const triggerContainer = screen.getByTestId('trigger').closest('div')
|
||||
if (!triggerContainer)
|
||||
throw new Error('Trigger container not found')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(triggerContainer)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close after delay on mouse leave when trigger is "hover"', async () => {
|
||||
vi.useRealTimers()
|
||||
const user = userEvent.setup()
|
||||
render(<CustomPopover {...defaultProps} />)
|
||||
|
||||
const trigger = screen.getByTestId('trigger')
|
||||
|
||||
await user.hover(trigger)
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
await user.unhover(trigger)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
}, { timeout: 2000 })
|
||||
})
|
||||
|
||||
it('should stay open when hovering over the popover content', async () => {
|
||||
vi.useRealTimers()
|
||||
const user = userEvent.setup()
|
||||
render(<CustomPopover {...defaultProps} />)
|
||||
|
||||
const trigger = screen.getByTestId('trigger')
|
||||
await user.hover(trigger)
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
// Leave trigger but enter content
|
||||
await user.unhover(trigger)
|
||||
const content = screen.getByTestId('content')
|
||||
await user.hover(content)
|
||||
|
||||
// Wait for the timeout duration
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
})
|
||||
|
||||
// Should still be open because we are hovering the content
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
// Now leave content
|
||||
await user.unhover(content)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
}, { timeout: 2000 })
|
||||
})
|
||||
|
||||
it('should cancel close timeout when re-entering during hover delay', async () => {
|
||||
render(<CustomPopover {...defaultProps} />)
|
||||
|
||||
const triggerContainer = screen.getByTestId('trigger').closest('div')
|
||||
if (!triggerContainer)
|
||||
throw new Error('Trigger container not found')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(triggerContainer)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseLeave(triggerContainer!)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(50) // Halfway through timeout
|
||||
fireEvent.mouseEnter(triggerContainer!)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000) // Much longer than the original timeout
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open when disabled', async () => {
|
||||
render(<CustomPopover {...defaultProps} disabled={true} trigger="click" />)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass close function to htmlContent when manualClose is true', async () => {
|
||||
vi.useRealTimers()
|
||||
|
||||
render(
|
||||
<CustomPopover
|
||||
{...defaultProps}
|
||||
htmlContent={<CloseButtonContent />}
|
||||
trigger="click"
|
||||
manualClose={true}
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('content'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not close when mouse leaves while already closed', async () => {
|
||||
render(<CustomPopover {...defaultProps} />)
|
||||
const triggerContainer = screen.getByTestId('trigger').closest('div')
|
||||
if (!triggerContainer)
|
||||
throw new Error('Trigger container not found')
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.mouseLeave(triggerContainer)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom class names', async () => {
|
||||
render(
|
||||
<CustomPopover
|
||||
{...defaultProps}
|
||||
trigger="click"
|
||||
className="wrapper-class"
|
||||
popupClassName="popup-inner-class"
|
||||
btnClassName="btn-class"
|
||||
/>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId('trigger'))
|
||||
})
|
||||
|
||||
expect(document.querySelector('.wrapper-class')).toBeInTheDocument()
|
||||
expect(document.querySelector('.popup-inner-class')).toBeInTheDocument()
|
||||
|
||||
const button = screen.getByTestId('trigger').parentElement
|
||||
expect(button).toHaveClass('btn-class')
|
||||
})
|
||||
|
||||
it('should handle btnClassName as a function', () => {
|
||||
render(
|
||||
<CustomPopover
|
||||
{...defaultProps}
|
||||
btnClassName={open => open ? 'btn-open' : 'btn-closed'}
|
||||
/>,
|
||||
)
|
||||
|
||||
const button = screen.getByTestId('trigger').parentElement
|
||||
expect(button).toHaveClass('btn-closed')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import ProgressCircle from './progress-circle'
|
||||
|
||||
const extractLargeArcFlag = (pathData: string): string => {
|
||||
const afterA = pathData.slice(pathData.indexOf('A') + 1)
|
||||
const tokens = afterA.replace(/,/g, ' ').trim().split(/\s+/)
|
||||
// Arc syntax: A rx ry x-axis-rotation large-arc-flag sweep-flag x y
|
||||
return tokens[3]
|
||||
}
|
||||
|
||||
describe('ProgressCircle', () => {
|
||||
describe('Render', () => {
|
||||
it('renders an SVG with default props', () => {
|
||||
const { container } = render(<ProgressCircle />)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
const circle = container.querySelector('circle')
|
||||
const path = container.querySelector('path')
|
||||
|
||||
expect(svg).toBeInTheDocument()
|
||||
expect(circle).toBeInTheDocument()
|
||||
expect(path).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('applies correct size and viewBox when size is provided', () => {
|
||||
const size = 24
|
||||
const strokeWidth = 2
|
||||
|
||||
const { container } = render(
|
||||
<ProgressCircle size={size} circleStrokeWidth={strokeWidth} />,
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg') as SVGElement
|
||||
|
||||
expect(svg).toHaveAttribute('width', String(size + strokeWidth))
|
||||
expect(svg).toHaveAttribute('height', String(size + strokeWidth))
|
||||
expect(svg).toHaveAttribute(
|
||||
'viewBox',
|
||||
`0 0 ${size + strokeWidth} ${size + strokeWidth}`,
|
||||
)
|
||||
})
|
||||
|
||||
it('applies custom stroke and fill classes to the circle', () => {
|
||||
const { container } = render(
|
||||
<ProgressCircle
|
||||
circleStrokeColor="stroke-red-500"
|
||||
circleFillColor="fill-red-100"
|
||||
/>,
|
||||
)
|
||||
const circle = container.querySelector('circle')!
|
||||
expect(circle!).toHaveClass('stroke-red-500')
|
||||
expect(circle!).toHaveClass('fill-red-100')
|
||||
})
|
||||
|
||||
it('applies custom sector fill color to the path', () => {
|
||||
const { container } = render(
|
||||
<ProgressCircle sectorFillColor="fill-blue-500" />,
|
||||
)
|
||||
const path = container.querySelector('path')!
|
||||
expect(path!).toHaveClass('fill-blue-500')
|
||||
})
|
||||
|
||||
it('uses large arc flag when percentage is greater than 50', () => {
|
||||
const { container } = render(<ProgressCircle percentage={75} />)
|
||||
const path = container.querySelector('path')!
|
||||
const d = path.getAttribute('d') || ''
|
||||
expect(d).toContain('A')
|
||||
expect(extractLargeArcFlag(d)).toBe('1')
|
||||
})
|
||||
|
||||
it('uses small arc flag when percentage is 50 or less', () => {
|
||||
const { container } = render(<ProgressCircle percentage={25} />)
|
||||
const path = container.querySelector('path')!
|
||||
const d = path.getAttribute('d') || ''
|
||||
expect(d).toContain('A')
|
||||
expect(extractLargeArcFlag(d)).toBe('0')
|
||||
})
|
||||
|
||||
it('uses small arc flag when percentage is exactly 50', () => {
|
||||
const { container } = render(<ProgressCircle percentage={50} />)
|
||||
const path = container.querySelector('path')!
|
||||
const d = path.getAttribute('d') || ''
|
||||
expect(d).toContain('A')
|
||||
expect(extractLargeArcFlag(d)).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
25
web/app/components/base/prompt-log-modal/card.spec.tsx
Normal file
25
web/app/components/base/prompt-log-modal/card.spec.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Card from './card'
|
||||
|
||||
describe('PromptLogModal Card', () => {
|
||||
it('renders single log entry correctly', () => {
|
||||
const log = [{ role: 'user', text: 'Single entry text' }]
|
||||
render(<Card log={log} />)
|
||||
|
||||
expect(screen.getByText('Single entry text')).toBeInTheDocument()
|
||||
expect(screen.queryByText('USER')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders multiple log entries correctly', () => {
|
||||
const log = [
|
||||
{ role: 'user', text: 'Message 1' },
|
||||
{ role: 'assistant', text: 'Message 2' },
|
||||
]
|
||||
render(<Card log={log} />)
|
||||
|
||||
expect(screen.getByText('USER')).toBeInTheDocument()
|
||||
expect(screen.getByText('ASSISTANT')).toBeInTheDocument()
|
||||
expect(screen.getByText('Message 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Message 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
60
web/app/components/base/prompt-log-modal/index.spec.tsx
Normal file
60
web/app/components/base/prompt-log-modal/index.spec.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PromptLogModal from '.'
|
||||
|
||||
describe('PromptLogModal', () => {
|
||||
const defaultProps = {
|
||||
width: 1000,
|
||||
onCancel: vi.fn(),
|
||||
currentLogItem: {
|
||||
id: '1',
|
||||
content: 'test',
|
||||
log: [{ role: 'user', text: 'Hello' }],
|
||||
} as Parameters<typeof PromptLogModal>[0]['currentLogItem'],
|
||||
}
|
||||
|
||||
describe('Render', () => {
|
||||
it('renders correctly when currentLogItem is provided', () => {
|
||||
render(<PromptLogModal {...defaultProps} />)
|
||||
expect(screen.getByText('PROMPT LOG')).toBeInTheDocument()
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns null when currentLogItem is missing', () => {
|
||||
const { container } = render(<PromptLogModal {...defaultProps} currentLogItem={undefined} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders copy feedback when log length is 1', () => {
|
||||
render(<PromptLogModal {...defaultProps} />)
|
||||
expect(screen.getByTestId('close-btn-container')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('calls onCancel when close button is clicked', () => {
|
||||
render(<PromptLogModal {...defaultProps} />)
|
||||
const closeBtn = screen.getByTestId('close-btn')
|
||||
expect(closeBtn).toBeInTheDocument()
|
||||
fireEvent.click(closeBtn)
|
||||
expect(defaultProps.onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onCancel when clicking outside', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="outside">Outside</div>
|
||||
<PromptLogModal {...defaultProps} onCancel={onCancel} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('close-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('outside'))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import type { IChatItem } from '@/app/components/base/chat/chat/type'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { CopyFeedbackNew } from '@/app/components/base/copy-feedback'
|
||||
@@ -57,8 +56,9 @@ const PromptLogModal: FC<PromptLogModalProps> = ({
|
||||
<div
|
||||
onClick={onCancel}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center"
|
||||
data-testid="close-btn-container"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" data-testid="close-btn" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
94
web/app/components/base/qrcode/index.spec.tsx
Normal file
94
web/app/components/base/qrcode/index.spec.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import ShareQRCode from '.'
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ShareQRCode', () => {
|
||||
const content = 'https://example.com'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<ShareQRCode content={content} />)
|
||||
expect(screen.getByRole('button').firstElementChild).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('toggles QR code panel when clicking the icon', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ShareQRCode content={content} />)
|
||||
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
const trigger = screen.getByTestId('qrcode-container')
|
||||
await user.click(trigger)
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes panel when clicking outside', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="outside">Outside</div>
|
||||
<ShareQRCode content={content} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByTestId('qrcode-container')
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('img')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByTestId('outside'))
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not close panel when clicking inside the panel', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ShareQRCode content={content} />)
|
||||
|
||||
const trigger = screen.getByTestId('qrcode-container')
|
||||
await user.click(trigger)
|
||||
|
||||
const canvas = screen.getByRole('img')
|
||||
const panel = canvas.parentElement
|
||||
await user.click(panel!)
|
||||
|
||||
expect(canvas).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls downloadUrl when clicking download', async () => {
|
||||
const user = userEvent.setup()
|
||||
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL
|
||||
HTMLCanvasElement.prototype.toDataURL = vi.fn(() => 'data:image/png;base64,test')
|
||||
|
||||
try {
|
||||
render(<ShareQRCode content={content} />)
|
||||
|
||||
const trigger = screen.getByTestId('qrcode-container')
|
||||
await user.click(trigger!)
|
||||
|
||||
const downloadBtn = screen.getByText('appOverview.overview.appInfo.qrcode.download')
|
||||
await user.click(downloadBtn)
|
||||
|
||||
expect(downloadUrl).toHaveBeenCalledWith({
|
||||
url: 'data:image/png;base64,test',
|
||||
fileName: 'qrcode.png',
|
||||
})
|
||||
}
|
||||
finally {
|
||||
HTMLCanvasElement.prototype.toDataURL = originalToDataURL
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,4 @@
|
||||
'use client'
|
||||
import {
|
||||
RiQrCodeLine,
|
||||
} from '@remixicon/react'
|
||||
import { QRCodeCanvas as QRCode } from 'qrcode.react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -55,9 +52,9 @@ const ShareQRCode = ({ content }: Props) => {
|
||||
<Tooltip
|
||||
popupContent={t(`${prefixEmbedded}`, { ns: 'appOverview' }) || ''}
|
||||
>
|
||||
<div className="relative h-6 w-6" onClick={toggleQRCode}>
|
||||
<div className="relative h-6 w-6" onClick={toggleQRCode} data-testid="qrcode-container">
|
||||
<ActionButton>
|
||||
<RiQrCodeLine className="h-4 w-4" />
|
||||
<span className="i-ri-qr-code-line h-4 w-4" />
|
||||
</ActionButton>
|
||||
{isShow && (
|
||||
<div
|
||||
@@ -66,7 +63,7 @@ const ShareQRCode = ({ content }: Props) => {
|
||||
onClick={handlePanelClick}
|
||||
>
|
||||
<QRCode size={160} value={content} className="mb-2" />
|
||||
<div className="system-xs-regular flex items-center">
|
||||
<div className="flex items-center system-xs-regular">
|
||||
<div className="text-text-tertiary">{t('overview.appInfo.qrcode.scan', { ns: 'appOverview' })}</div>
|
||||
<div className="text-text-tertiary">·</div>
|
||||
<div className="cursor-pointer text-text-accent-secondary" onClick={downloadQR}>{t('overview.appInfo.qrcode.download', { ns: 'appOverview' })}</div>
|
||||
|
||||
44
web/app/components/base/simple-pie-chart/index.spec.tsx
Normal file
44
web/app/components/base/simple-pie-chart/index.spec.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import SimplePieChart from '.'
|
||||
|
||||
describe('SimplePieChart', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<SimplePieChart />)
|
||||
const chart = container.querySelector('.echarts-for-react')
|
||||
expect(chart).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<SimplePieChart className="custom-chart" />)
|
||||
const chart = container.querySelector('.echarts-for-react')
|
||||
expect(chart).toHaveClass('custom-chart')
|
||||
})
|
||||
|
||||
it('should apply custom size via style', () => {
|
||||
const { container } = render(<SimplePieChart size={24} />)
|
||||
const chart = container.querySelector('.echarts-for-react') as HTMLElement
|
||||
expect(chart).toHaveStyle({ width: '24px', height: '24px' })
|
||||
})
|
||||
|
||||
it('should apply default size of 12', () => {
|
||||
const { container } = render(<SimplePieChart />)
|
||||
const chart = container.querySelector('.echarts-for-react') as HTMLElement
|
||||
expect(chart).toHaveStyle({ width: '12px', height: '12px' })
|
||||
})
|
||||
|
||||
it('should set custom fill color as CSS variable', () => {
|
||||
const { container } = render(<SimplePieChart fill="red" />)
|
||||
const chart = container.querySelector('.echarts-for-react') as HTMLElement
|
||||
expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('red')
|
||||
})
|
||||
|
||||
it('should set default fill color as CSS variable', () => {
|
||||
const { container } = render(<SimplePieChart />)
|
||||
const chart = container.querySelector('.echarts-for-react') as HTMLElement
|
||||
expect(chart.style.getPropertyValue('--simple-pie-chart-color')).toBe('#fdb022')
|
||||
})
|
||||
})
|
||||
})
|
||||
137
web/app/components/base/svg-gallery/index.spec.tsx
Normal file
137
web/app/components/base/svg-gallery/index.spec.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SVGRenderer from '.'
|
||||
|
||||
const mockClick = vi.fn()
|
||||
const mockSvg = vi.fn().mockReturnValue({
|
||||
click: mockClick,
|
||||
})
|
||||
const mockViewbox = vi.fn()
|
||||
const mockAddTo = vi.fn()
|
||||
|
||||
vi.mock('@svgdotjs/svg.js', () => ({
|
||||
SVG: vi.fn().mockImplementation(() => ({
|
||||
addTo: mockAddTo,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('dompurify', () => ({
|
||||
default: {
|
||||
sanitize: vi.fn(content => content),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SVGRenderer', () => {
|
||||
const validSvg = '<svg width="100" height="100"><circle cx="50" cy="50" r="40" /></svg>'
|
||||
let parseFromStringSpy: ReturnType<typeof vi.spyOn>
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAddTo.mockReturnValue({
|
||||
viewbox: mockViewbox,
|
||||
svg: mockSvg,
|
||||
})
|
||||
mockSvg.mockReturnValue({
|
||||
click: mockClick,
|
||||
})
|
||||
|
||||
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
mockSvgElement.setAttribute('width', '100')
|
||||
mockSvgElement.setAttribute('height', '100')
|
||||
parseFromStringSpy = vi.spyOn(DOMParser.prototype, 'parseFromString').mockReturnValue({
|
||||
documentElement: mockSvgElement,
|
||||
} as unknown as Document)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders correctly with content', async () => {
|
||||
render(<SVGRenderer content={validSvg} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockViewbox).toHaveBeenCalledWith(0, 0, 100, 100)
|
||||
})
|
||||
expect(mockSvg).toHaveBeenCalledWith(validSvg)
|
||||
})
|
||||
|
||||
it('shows error message on invalid SVG content', async () => {
|
||||
parseFromStringSpy.mockReturnValue({
|
||||
documentElement: document.createElement('div'),
|
||||
} as unknown as Document)
|
||||
|
||||
render(<SVGRenderer content="invalid" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Error rendering SVG/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('re-renders on window resize', async () => {
|
||||
render(<SVGRenderer content={validSvg} />)
|
||||
await waitFor(() => {
|
||||
expect(mockAddTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddTo).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('uses default values for width/height if not present', async () => {
|
||||
const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
||||
parseFromStringSpy.mockReturnValue({
|
||||
documentElement: mockSvgElement,
|
||||
} as unknown as Document)
|
||||
|
||||
render(<SVGRenderer content="<svg></svg>" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockViewbox).toHaveBeenCalledWith(0, 0, 400, 600)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Image Preview Interactions', () => {
|
||||
it('opens image preview on click', async () => {
|
||||
render(<SVGRenderer content={validSvg} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
const clickHandler = mockClick.mock.calls[0][0]
|
||||
|
||||
await act(async () => {
|
||||
clickHandler()
|
||||
})
|
||||
const img = screen.getByAltText('Preview')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute(
|
||||
'src',
|
||||
expect.stringContaining('data:image/svg+xml;base64'),
|
||||
)
|
||||
})
|
||||
|
||||
it('closes image preview on cancel', async () => {
|
||||
render(<SVGRenderer content={validSvg} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClick).toHaveBeenCalled()
|
||||
})
|
||||
const clickHandler = mockClick.mock.calls[0][0]
|
||||
await act(async () => {
|
||||
clickHandler()
|
||||
})
|
||||
|
||||
expect(screen.getByAltText('Preview')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(screen.queryByAltText('Preview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
44
web/app/components/base/svg/index.spec.tsx
Normal file
44
web/app/components/base/svg/index.spec.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SVGBtn from '.'
|
||||
|
||||
describe('SVGBtn', () => {
|
||||
describe('Rendering', () => {
|
||||
it('renders correctly', () => {
|
||||
const setIsSVG = vi.fn()
|
||||
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('calls setIsSVG with a toggle function when clicked', () => {
|
||||
const setIsSVG = vi.fn()
|
||||
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
|
||||
|
||||
const button = screen.getByRole('button')
|
||||
fireEvent.click(button)
|
||||
|
||||
expect(setIsSVG).toHaveBeenCalledTimes(1)
|
||||
const toggleFunc = setIsSVG.mock.calls[0][0]
|
||||
expect(typeof toggleFunc).toBe('function')
|
||||
expect(toggleFunc(false)).toBe(true)
|
||||
expect(toggleFunc(true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('applies correct class when isSVG is false', () => {
|
||||
const setIsSVG = vi.fn()
|
||||
render(<SVGBtn isSVG={false} setIsSVG={setIsSVG} />)
|
||||
const icon = screen.getByRole('button').firstChild as HTMLElement
|
||||
expect(icon?.className).toMatch(/_svgIcon_\w+/)
|
||||
})
|
||||
|
||||
it('applies correct class when isSVG is true', () => {
|
||||
const setIsSVG = vi.fn()
|
||||
render(<SVGBtn isSVG={true} setIsSVG={setIsSVG} />)
|
||||
const icon = screen.getByRole('button').firstChild as HTMLElement
|
||||
expect(icon?.className).toMatch(/_svgIconed_\w+/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,41 +4,54 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import Switch from './index'
|
||||
|
||||
describe('Switch', () => {
|
||||
it('should render in unchecked state by default', () => {
|
||||
render(<Switch />)
|
||||
it('should render in unchecked state when value is false', () => {
|
||||
render(<Switch value={false} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toBeInTheDocument()
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should render in checked state when defaultValue is true', () => {
|
||||
render(<Switch defaultValue={true} />)
|
||||
it('should render in checked state when value is true', () => {
|
||||
render(<Switch value={true} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should toggle state and call onChange when clicked', async () => {
|
||||
it('should call onChange with next value when clicked', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Switch onChange={onChange} />)
|
||||
render(<Switch value={false} onChange={onChange} />)
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
|
||||
await user.click(switchElement)
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'true')
|
||||
expect(onChange).toHaveBeenCalledWith(true)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(switchElement)
|
||||
// Controlled component stays the same until parent updates value.
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'false')
|
||||
expect(onChange).toHaveBeenCalledWith(false)
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should work in controlled mode with value prop', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<Switch value={false} onChange={onChange} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await user.click(switchElement)
|
||||
expect(onChange).toHaveBeenCalledWith(true)
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
rerender(<Switch value={true} onChange={onChange} />)
|
||||
expect(switchElement).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should not call onChange when disabled', async () => {
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(<Switch disabled onChange={onChange} />)
|
||||
render(<Switch value={false} disabled onChange={onChange} />)
|
||||
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toHaveClass('!cursor-not-allowed', '!opacity-50')
|
||||
@@ -48,37 +61,36 @@ describe('Switch', () => {
|
||||
})
|
||||
|
||||
it('should apply correct size classes', () => {
|
||||
const { rerender } = render(<Switch size="xs" />)
|
||||
const { rerender } = render(<Switch value={false} size="xs" />)
|
||||
// We only need to find the element once
|
||||
const switchElement = screen.getByRole('switch')
|
||||
expect(switchElement).toHaveClass('h-2.5', 'w-3.5', 'rounded-sm')
|
||||
|
||||
rerender(<Switch size="sm" />)
|
||||
rerender(<Switch value={false} size="sm" />)
|
||||
expect(switchElement).toHaveClass('h-3', 'w-5')
|
||||
|
||||
rerender(<Switch size="md" />)
|
||||
rerender(<Switch value={false} size="md" />)
|
||||
expect(switchElement).toHaveClass('h-4', 'w-7')
|
||||
|
||||
rerender(<Switch size="l" />)
|
||||
rerender(<Switch value={false} size="l" />)
|
||||
expect(switchElement).toHaveClass('h-5', 'w-9')
|
||||
|
||||
rerender(<Switch size="lg" />)
|
||||
rerender(<Switch value={false} size="lg" />)
|
||||
expect(switchElement).toHaveClass('h-6', 'w-11')
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
render(<Switch className="custom-test-class" />)
|
||||
render(<Switch value={false} className="custom-test-class" />)
|
||||
expect(screen.getByRole('switch')).toHaveClass('custom-test-class')
|
||||
})
|
||||
|
||||
it('should apply correct background colors based on state', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Switch />)
|
||||
it('should apply correct background colors based on value prop', () => {
|
||||
const { rerender } = render(<Switch value={false} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
|
||||
expect(switchElement).toHaveClass('bg-components-toggle-bg-unchecked')
|
||||
|
||||
await user.click(switchElement)
|
||||
rerender(<Switch value={true} />)
|
||||
expect(switchElement).toHaveClass('bg-components-toggle-bg')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,15 +14,18 @@ const meta = {
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
value: false,
|
||||
},
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'l'],
|
||||
description: 'Switch size',
|
||||
},
|
||||
defaultValue: {
|
||||
value: {
|
||||
control: 'boolean',
|
||||
description: 'Default checked state',
|
||||
description: 'Checked state (controlled)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
@@ -36,14 +39,14 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SwitchDemo = (args: any) => {
|
||||
const [enabled, setEnabled] = useState(args.defaultValue || false)
|
||||
const [enabled, setEnabled] = useState(args.value ?? false)
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
defaultValue={enabled}
|
||||
value={enabled}
|
||||
onChange={(value) => {
|
||||
setEnabled(value)
|
||||
console.log('Switch toggled:', value)
|
||||
@@ -62,7 +65,7 @@ export const Default: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
value: false,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
@@ -72,7 +75,7 @@ export const DefaultOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: true,
|
||||
value: true,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
@@ -82,7 +85,7 @@ export const DisabledOff: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
value: false,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
@@ -92,7 +95,7 @@ export const DisabledOn: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: true,
|
||||
value: true,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
@@ -111,31 +114,31 @@ const SizeComparisonDemo = () => {
|
||||
<div style={{ width: '400px' }} className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" defaultValue={states.xs} onChange={v => setStates({ ...states, xs: v })} />
|
||||
<Switch size="xs" value={states.xs} onChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" defaultValue={states.sm} onChange={v => setStates({ ...states, sm: v })} />
|
||||
<Switch size="sm" value={states.sm} onChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" defaultValue={states.md} onChange={v => setStates({ ...states, md: v })} />
|
||||
<Switch size="md" value={states.md} onChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Medium (md)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="l" defaultValue={states.l} onChange={v => setStates({ ...states, l: v })} />
|
||||
<Switch size="l" value={states.l} onChange={v => setStates({ ...states, l: v })} />
|
||||
<span className="text-sm text-gray-700">Large (l)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" defaultValue={states.lg} onChange={v => setStates({ ...states, lg: v })} />
|
||||
<Switch size="lg" value={states.lg} onChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Large (lg)</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +163,7 @@ const WithLabelsDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={enabled}
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -197,7 +200,7 @@ const SettingsPanelDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.notifications}
|
||||
value={settings.notifications}
|
||||
onChange={v => updateSetting('notifications', v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -209,7 +212,7 @@ const SettingsPanelDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.autoSave}
|
||||
value={settings.autoSave}
|
||||
onChange={v => updateSetting('autoSave', v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,7 +224,7 @@ const SettingsPanelDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.darkMode}
|
||||
value={settings.darkMode}
|
||||
onChange={v => updateSetting('darkMode', v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -233,7 +236,7 @@ const SettingsPanelDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.analytics}
|
||||
value={settings.analytics}
|
||||
onChange={v => updateSetting('analytics', v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -245,7 +248,7 @@ const SettingsPanelDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={settings.emailUpdates}
|
||||
value={settings.emailUpdates}
|
||||
onChange={v => updateSetting('emailUpdates', v)}
|
||||
/>
|
||||
</div>
|
||||
@@ -279,7 +282,7 @@ const PrivacyControlsDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.profilePublic}
|
||||
value={privacy.profilePublic}
|
||||
onChange={v => setPrivacy({ ...privacy, profilePublic: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -291,7 +294,7 @@ const PrivacyControlsDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.showEmail}
|
||||
value={privacy.showEmail}
|
||||
onChange={v => setPrivacy({ ...privacy, showEmail: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -303,7 +306,7 @@ const PrivacyControlsDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.allowMessages}
|
||||
value={privacy.allowMessages}
|
||||
onChange={v => setPrivacy({ ...privacy, allowMessages: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,7 +318,7 @@ const PrivacyControlsDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={privacy.shareActivity}
|
||||
value={privacy.shareActivity}
|
||||
onChange={v => setPrivacy({ ...privacy, shareActivity: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -351,7 +354,7 @@ const FeatureTogglesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.betaFeatures}
|
||||
value={features.betaFeatures}
|
||||
onChange={v => setFeatures({ ...features, betaFeatures: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -366,7 +369,7 @@ const FeatureTogglesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.experimentalUI}
|
||||
value={features.experimentalUI}
|
||||
onChange={v => setFeatures({ ...features, experimentalUI: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -381,7 +384,7 @@ const FeatureTogglesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.advancedMode}
|
||||
value={features.advancedMode}
|
||||
onChange={v => setFeatures({ ...features, advancedMode: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -396,7 +399,7 @@ const FeatureTogglesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={features.developerTools}
|
||||
value={features.developerTools}
|
||||
onChange={v => setFeatures({ ...features, developerTools: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -440,7 +443,7 @@ const NotificationPreferencesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.email}
|
||||
value={notifications.email}
|
||||
onChange={v => setNotifications({ ...notifications, email: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -455,7 +458,7 @@ const NotificationPreferencesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.push}
|
||||
value={notifications.push}
|
||||
onChange={v => setNotifications({ ...notifications, push: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -470,7 +473,7 @@ const NotificationPreferencesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.sms}
|
||||
value={notifications.sms}
|
||||
onChange={v => setNotifications({ ...notifications, sms: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -485,7 +488,7 @@ const NotificationPreferencesDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={notifications.desktop}
|
||||
value={notifications.desktop}
|
||||
onChange={v => setNotifications({ ...notifications, desktop: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -523,7 +526,7 @@ const APIAccessControlDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.readAccess}
|
||||
value={access.readAccess}
|
||||
onChange={v => setAccess({ ...access, readAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -539,7 +542,7 @@ const APIAccessControlDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.writeAccess}
|
||||
value={access.writeAccess}
|
||||
onChange={v => setAccess({ ...access, writeAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -555,7 +558,7 @@ const APIAccessControlDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.deleteAccess}
|
||||
value={access.deleteAccess}
|
||||
onChange={v => setAccess({ ...access, deleteAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -571,7 +574,7 @@ const APIAccessControlDemo = () => {
|
||||
</div>
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={access.adminAccess}
|
||||
value={access.adminAccess}
|
||||
onChange={v => setAccess({ ...access, adminAccess: v })}
|
||||
/>
|
||||
</div>
|
||||
@@ -609,7 +612,7 @@ const CompactListDemo = () => {
|
||||
<span className="text-sm text-gray-700">{item.name}</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
defaultValue={item.enabled}
|
||||
value={item.enabled}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
/>
|
||||
</div>
|
||||
@@ -628,7 +631,7 @@ export const Playground: Story = {
|
||||
render: args => <SwitchDemo {...args} />,
|
||||
args: {
|
||||
size: 'md',
|
||||
defaultValue: false,
|
||||
value: false,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
import { Switch as OriginalSwitch } from '@headlessui/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SwitchProps = {
|
||||
value: boolean
|
||||
onChange?: (value: boolean) => void
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'l'
|
||||
defaultValue?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
@@ -15,19 +14,15 @@ type SwitchProps = {
|
||||
const Switch = (
|
||||
{
|
||||
ref: propRef,
|
||||
value,
|
||||
onChange,
|
||||
size = 'md',
|
||||
defaultValue = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: SwitchProps & {
|
||||
ref?: React.RefObject<HTMLButtonElement>
|
||||
},
|
||||
) => {
|
||||
const [enabled, setEnabled] = useState(defaultValue)
|
||||
useEffect(() => {
|
||||
setEnabled(defaultValue)
|
||||
}, [defaultValue])
|
||||
const wrapStyle = {
|
||||
lg: 'h-6 w-11',
|
||||
l: 'h-5 w-9',
|
||||
@@ -54,18 +49,17 @@ const Switch = (
|
||||
return (
|
||||
<OriginalSwitch
|
||||
ref={propRef}
|
||||
checked={enabled}
|
||||
checked={value}
|
||||
onChange={(checked: boolean) => {
|
||||
if (disabled)
|
||||
return
|
||||
setEnabled(checked)
|
||||
onChange?.(checked)
|
||||
}}
|
||||
className={cn(wrapStyle[size], enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked', 'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out', disabled ? '!cursor-not-allowed !opacity-50' : '', size === 'xs' && 'rounded-sm', className)}
|
||||
className={cn(wrapStyle[size], value ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked', 'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out', disabled ? '!cursor-not-allowed !opacity-50' : '', size === 'xs' && 'rounded-sm', className)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(circleStyle[size], enabled ? translateLeft[size] : 'translate-x-0', size === 'xs' && 'rounded-[1px]', 'pointer-events-none inline-block rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out')}
|
||||
className={cn(circleStyle[size], value ? translateLeft[size] : 'translate-x-0', size === 'xs' && 'rounded-[1px]', 'pointer-events-none inline-block rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out')}
|
||||
/>
|
||||
</OriginalSwitch>
|
||||
)
|
||||
|
||||
@@ -24,12 +24,12 @@ const PlanRangeSwitcher: FC<PlanRangeSwitcherProps> = ({
|
||||
<div className="flex items-center justify-end gap-x-3 pr-5">
|
||||
<Switch
|
||||
size="l"
|
||||
defaultValue={value === PlanRange.yearly}
|
||||
value={value === PlanRange.yearly}
|
||||
onChange={(v) => {
|
||||
onChange(v ? PlanRange.yearly : PlanRange.monthly)
|
||||
}}
|
||||
/>
|
||||
<span className="system-md-regular text-text-tertiary">
|
||||
<span className="text-text-tertiary system-md-regular">
|
||||
{t('plansCommon.annualBilling', { ns: 'billing', percent: 17 })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
* @example "50MB" -> 50, "5GB" -> 5120, "20GB" -> 20480
|
||||
*/
|
||||
export const parseVectorSpaceToMB = (vectorSpace: string): number => {
|
||||
const match = vectorSpace.match(/^(\d+)(MB|GB)$/i)
|
||||
const match = /^(\d+)(MB|GB)$/i.exec(vectorSpace)
|
||||
if (!match)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -116,19 +116,19 @@ const CustomWebAppBrand = () => {
|
||||
|
||||
return (
|
||||
<div className="py-4">
|
||||
<div className="system-md-medium mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary">
|
||||
<div className="mb-2 flex items-center justify-between rounded-xl bg-background-section-burn p-4 text-text-primary system-md-medium">
|
||||
{t('webapp.removeBrand', { ns: 'custom' })}
|
||||
<Switch
|
||||
size="l"
|
||||
defaultValue={webappBrandRemoved}
|
||||
value={webappBrandRemoved ?? false}
|
||||
disabled={isSandbox || !isCurrentWorkspaceManager}
|
||||
onChange={handleSwitch}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex h-14 items-center justify-between rounded-xl bg-background-section-burn px-4', webappBrandRemoved && 'opacity-30')}>
|
||||
<div>
|
||||
<div className="system-md-medium text-text-primary">{t('webapp.changeLogo', { ns: 'custom' })}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{t('webapp.changeLogoTip', { ns: 'custom' })}</div>
|
||||
<div className="text-text-primary system-md-medium">{t('webapp.changeLogo', { ns: 'custom' })}</div>
|
||||
<div className="text-text-tertiary system-xs-regular">{t('webapp.changeLogoTip', { ns: 'custom' })}</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{(!uploadDisabled && webappLogo && !webappBrandRemoved) && (
|
||||
@@ -204,7 +204,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="mt-2 text-xs text-[#D92D20]">{t('uploadedFail', { ns: 'custom' })}</div>
|
||||
)}
|
||||
<div className="mb-2 mt-5 flex items-center gap-2">
|
||||
<div className="system-xs-medium-uppercase shrink-0 text-text-tertiary">{t('overview.appInfo.preview', { ns: 'appOverview' })}</div>
|
||||
<div className="shrink-0 text-text-tertiary system-xs-medium-uppercase">{t('overview.appInfo.preview', { ns: 'appOverview' })}</div>
|
||||
<Divider bgStyle="gradient" className="grow" />
|
||||
</div>
|
||||
<div className="relative mb-2 flex items-center gap-3">
|
||||
@@ -215,7 +215,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-blue-light-solid')}>
|
||||
<BubbleTextMod className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="system-md-semibold grow text-text-secondary">Chatflow App</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Chatflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
@@ -262,12 +262,12 @@ const CustomWebAppBrand = () => {
|
||||
<div className="flex w-[138px] grow flex-col justify-between p-2 pr-0">
|
||||
<div className="flex grow flex-col justify-between rounded-l-2xl border-[0.5px] border-r-0 border-components-panel-border-subtle bg-chatbot-bg pb-4 pl-[22px] pt-16">
|
||||
<div className="w-[720px] rounded-2xl border border-divider-subtle bg-chat-bubble-bg px-4 py-3">
|
||||
<div className="body-md-regular mb-1 text-text-primary">Hello! How can I assist you today?</div>
|
||||
<div className="mb-1 text-text-primary body-md-regular">Hello! How can I assist you today?</div>
|
||||
<Button size="small">
|
||||
<div className="h-2 w-[144px] rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="body-lg-regular flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm">Talk to Dify</div>
|
||||
<div className="flex h-[52px] w-[578px] items-center rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pl-3.5 text-text-placeholder shadow-md backdrop-blur-sm body-lg-regular">Talk to Dify</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,14 +278,14 @@ const CustomWebAppBrand = () => {
|
||||
<div className={cn('inline-flex h-8 w-8 items-center justify-center rounded-lg border border-divider-regular', 'bg-components-icon-bg-indigo-solid')}>
|
||||
<RiExchange2Fill className="h-4 w-4 text-components-avatar-shape-fill-stop-100" />
|
||||
</div>
|
||||
<div className="system-md-semibold grow text-text-secondary">Workflow App</div>
|
||||
<div className="grow text-text-secondary system-md-semibold">Workflow App</div>
|
||||
<div className="p-1.5">
|
||||
<RiLayoutLeft2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="system-md-semibold-uppercase flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary">RUN ONCE</div>
|
||||
<div className="system-md-semibold-uppercase flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary">RUN BATCH</div>
|
||||
<div className="flex h-10 shrink-0 items-center border-b-2 border-components-tab-active text-text-primary system-md-semibold-uppercase">RUN ONCE</div>
|
||||
<div className="flex h-10 grow items-center border-b-2 border-transparent text-text-tertiary system-md-semibold-uppercase">RUN BATCH</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow bg-components-panel-bg">
|
||||
@@ -293,7 +293,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="mb-1 py-2">
|
||||
<div className="h-2 w-20 rounded-sm bg-text-quaternary opacity-20"></div>
|
||||
</div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal "></div>
|
||||
<div className="h-16 w-full rounded-lg bg-components-input-bg-normal"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<Button size="small">
|
||||
@@ -308,7 +308,7 @@ const CustomWebAppBrand = () => {
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 bg-components-panel-bg p-4 pt-3">
|
||||
{!webappBrandRemoved && (
|
||||
<>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">POWERED BY</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">POWERED BY</div>
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt="logo" className="block h-5 w-auto" />
|
||||
|
||||
@@ -98,8 +98,8 @@ describe('CredentialIcon', () => {
|
||||
const classes1 = wrapper1.className
|
||||
const classes2 = wrapper2.className
|
||||
|
||||
const bgClass1 = classes1.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
const bgClass2 = classes2.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
const bgClass1 = /bg-components-icon-bg-\S+/.exec(classes1)?.[0]
|
||||
const bgClass2 = /bg-components-icon-bg-\S+/.exec(classes2)?.[0]
|
||||
|
||||
expect(bgClass1).toBe(bgClass2)
|
||||
})
|
||||
@@ -112,8 +112,8 @@ describe('CredentialIcon', () => {
|
||||
const wrapper1 = container1.firstChild as HTMLElement
|
||||
const wrapper2 = container2.firstChild as HTMLElement
|
||||
|
||||
const bgClass1 = wrapper1.className.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
const bgClass2 = wrapper2.className.match(/bg-components-icon-bg-\S+/)?.[0]
|
||||
const bgClass1 = /bg-components-icon-bg-\S+/.exec(wrapper1.className)?.[0]
|
||||
const bgClass2 = /bg-components-icon-bg-\S+/.exec(wrapper2.className)?.[0]
|
||||
|
||||
expect(bgClass1).toBeDefined()
|
||||
expect(bgClass2).toBeDefined()
|
||||
|
||||
@@ -123,11 +123,11 @@ vi.mock('@/app/components/base/radio-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/switch', () => ({
|
||||
default: ({ defaultValue, onChange }: { defaultValue: boolean, onChange: (v: boolean) => void }) => (
|
||||
default: ({ value, onChange }: { value: boolean, onChange?: (v: boolean) => void }) => (
|
||||
<button
|
||||
data-testid="rerank-switch"
|
||||
data-checked={defaultValue}
|
||||
onClick={() => onChange(!defaultValue)}
|
||||
data-checked={value}
|
||||
onClick={() => onChange?.(!value)}
|
||||
>
|
||||
Switch
|
||||
</button>
|
||||
|
||||
@@ -122,7 +122,7 @@ const RetrievalParamConfig: FC<Props> = ({
|
||||
{canToggleRerankModalEnable && (
|
||||
<Switch
|
||||
size="md"
|
||||
defaultValue={value.reranking_enable}
|
||||
value={value.reranking_enable}
|
||||
onChange={handleToggleRerankEnable}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -191,7 +191,7 @@ const Operations = ({
|
||||
return (
|
||||
<div className="flex items-center" onClick={e => e.stopPropagation()}>
|
||||
{isListScene && !embeddingAvailable && (
|
||||
<Switch defaultValue={false} onChange={noop} disabled={true} size="md" />
|
||||
<Switch value={false} onChange={noop} disabled={true} size="md" />
|
||||
)}
|
||||
{isListScene && embeddingAvailable && (
|
||||
<>
|
||||
@@ -202,11 +202,11 @@ const Operations = ({
|
||||
popupClassName="!font-semibold"
|
||||
>
|
||||
<div>
|
||||
<Switch defaultValue={false} onChange={noop} disabled={true} size="md" />
|
||||
<Switch value={false} onChange={noop} disabled={true} size="md" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
: <Switch defaultValue={enabled} onChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" />}
|
||||
: <Switch value={enabled} onChange={v => handleSwitch(v ? 'enable' : 'disable')} size="md" />}
|
||||
<Divider className="!ml-4 !mr-2 !h-3" type="vertical" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ const SegmentCard: FC<ISegmentCardProps> = ({
|
||||
<Switch
|
||||
size="md"
|
||||
disabled={archived || detail?.status !== 'completed'}
|
||||
defaultValue={enabled}
|
||||
value={enabled}
|
||||
onChange={async (val) => {
|
||||
await onChangeSwitch?.(val, id)
|
||||
}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user