mirror of
https://github.com/langgenius/dify.git
synced 2026-05-24 19:07:53 +08:00
Compare commits
16 Commits
codex/refi
...
codex/new-
| Author | SHA1 | Date | |
|---|---|---|---|
| cd1cb8f175 | |||
| 2dd683e613 | |||
| 7489893ea4 | |||
| 9dfb9a4b4d | |||
| 1012d84242 | |||
| 80ccec9457 | |||
| d8a48bc62f | |||
| 32270c3e63 | |||
| c6291e928d | |||
| 687377cc76 | |||
| fa730139a6 | |||
| 5fd873d033 | |||
| fb6a495fa5 | |||
| 99d5f80e68 | |||
| 6b1b1f3790 | |||
| 7c65975507 |
@ -1,6 +1,6 @@
|
||||
from flask_restx import Resource, marshal
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
import services
|
||||
@ -54,12 +54,13 @@ class CreateRagPipelineDatasetApi(Resource):
|
||||
yaml_content=payload.yaml_content,
|
||||
)
|
||||
try:
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
rag_pipeline_dsl_service = RagPipelineDslService(session)
|
||||
import_info = rag_pipeline_dsl_service.create_rag_pipeline_dataset(
|
||||
tenant_id=current_tenant_id,
|
||||
rag_pipeline_dataset_create_entity=rag_pipeline_dataset_create_entity,
|
||||
)
|
||||
session.commit()
|
||||
if rag_pipeline_dataset_create_entity.permission == "partial_members":
|
||||
DatasetPermissionService.update_partial_member_list(
|
||||
current_tenant_id,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from flask import request
|
||||
from flask_restx import Resource, fields, marshal_with # type: ignore
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import get_or_create_model, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -67,10 +67,12 @@ class RagPipelineImportApi(Resource):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
payload = RagPipelineImportPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
# Create service with session
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
# Use a plain Session so that caught exceptions inside the service
|
||||
# (which return FAILED status instead of re-raising) do not leave the
|
||||
# transaction in a closed state that a .begin() context manager cannot
|
||||
# handle. See app_import.py for the canonical pattern.
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = RagPipelineDslService(session)
|
||||
# Import app
|
||||
account = current_user
|
||||
result = import_service.import_rag_pipeline(
|
||||
account=account,
|
||||
@ -80,6 +82,10 @@ class RagPipelineImportApi(Resource):
|
||||
pipeline_id=payload.pipeline_id,
|
||||
dataset_name=payload.name,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
session.commit()
|
||||
|
||||
# Return appropriate status code based on result
|
||||
status = result.status
|
||||
@ -102,12 +108,14 @@ class RagPipelineImportConfirmApi(Resource):
|
||||
def post(self, import_id):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
# Create service with session
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = RagPipelineDslService(session)
|
||||
# Confirm import
|
||||
account = current_user
|
||||
result = import_service.confirm_import(import_id=import_id, account=account)
|
||||
if result.status == ImportStatus.FAILED:
|
||||
session.rollback()
|
||||
else:
|
||||
session.commit()
|
||||
|
||||
# Return appropriate status code based on result
|
||||
if result.status == ImportStatus.FAILED:
|
||||
@ -124,7 +132,7 @@ class RagPipelineImportCheckDependenciesApi(Resource):
|
||||
@edit_permission_required
|
||||
@marshal_with(pipeline_import_check_dependencies_model)
|
||||
def get(self, pipeline: Pipeline):
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
import_service = RagPipelineDslService(session)
|
||||
result = import_service.check_dependencies(pipeline=pipeline)
|
||||
|
||||
@ -142,7 +150,7 @@ class RagPipelineExportApi(Resource):
|
||||
# Add include_secret params
|
||||
query = IncludeSecretQuery.model_validate(request.args.to_dict())
|
||||
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
export_service = RagPipelineDslService(session)
|
||||
result = export_service.export_rag_pipeline_dsl(
|
||||
pipeline=pipeline, include_secret=query.include_secret == "true"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Union
|
||||
from typing import Any, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from core.rag.entities import RerankingModelConfig, WeightedScoreConfig
|
||||
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
|
||||
@ -101,3 +101,14 @@ class KnowledgeIndexNodeData(BaseNodeData):
|
||||
index_chunk_variable_selector: list[str]
|
||||
indexing_technique: str | None = None
|
||||
summary_index_setting: SummaryIndexSettingDict | None = None
|
||||
|
||||
@field_validator("summary_index_setting", mode="before")
|
||||
@classmethod
|
||||
def normalize_summary_index_setting(cls, v: Any) -> Any:
|
||||
"""Treat dicts with enable=None (or missing enable) as None (#36233)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, dict):
|
||||
if v.get("enable") is None:
|
||||
return None
|
||||
return v
|
||||
|
||||
@ -78,9 +78,9 @@ class CheckDependenciesPendingData(BaseModel):
|
||||
class RagPipelineDslService:
|
||||
"""Import, export, and inspect RAG pipeline DSL using the caller-owned session.
|
||||
|
||||
Controllers wrap this service in a SQLAlchemy transaction context, so methods must only flush interim changes when
|
||||
generated IDs are needed. Committing inside the service would close the caller's transaction and break later work in
|
||||
the same context manager.
|
||||
Callers pass a plain ``Session`` (not wrapped in ``.begin()``) and are responsible for calling
|
||||
``session.commit()`` on success or ``session.rollback()`` on failure. Methods here only flush
|
||||
when generated IDs are needed mid-operation; they never commit or rollback.
|
||||
"""
|
||||
|
||||
def __init__(self, session: Session):
|
||||
|
||||
@ -1571,19 +1571,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/pagination/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/pagination/type.ts": {
|
||||
"ts/no-empty-object-type": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/prompt-editor/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
|
||||
@ -40,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./pagination`, `./tabs`, `./toggle-group` | Pagination for page navigation; Tabs for panels; ToggleGroup for segmented modes. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
|
||||
Utilities:
|
||||
|
||||
|
||||
@ -77,6 +77,10 @@
|
||||
"types": "./src/number-field/index.tsx",
|
||||
"import": "./src/number-field/index.tsx"
|
||||
},
|
||||
"./pagination": {
|
||||
"types": "./src/pagination/index.tsx",
|
||||
"import": "./src/pagination/index.tsx"
|
||||
},
|
||||
"./radio": {
|
||||
"types": "./src/radio/index.tsx",
|
||||
"import": "./src/radio/index.tsx"
|
||||
|
||||
293
packages/dify-ui/src/pagination/__tests__/index.spec.tsx
Normal file
293
packages/dify-ui/src/pagination/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationNavigation,
|
||||
PaginationNext,
|
||||
PaginationPage,
|
||||
PaginationPageJump,
|
||||
PaginationPageList,
|
||||
PaginationPageSize,
|
||||
PaginationPrevious,
|
||||
PaginationRoot,
|
||||
PaginationSkeleton,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
async function renderPagination({
|
||||
page = 2,
|
||||
totalPages = 200,
|
||||
onPageChange = vi.fn(),
|
||||
pageSize = 25,
|
||||
onPageSizeChange = vi.fn(),
|
||||
}: {
|
||||
page?: number
|
||||
totalPages?: number
|
||||
onPageChange?: (page: number) => void
|
||||
pageSize?: number
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
} = {}) {
|
||||
const screen = await render(
|
||||
<PaginationRoot
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
data-testid="pagination"
|
||||
>
|
||||
<PaginationContent data-testid="content">
|
||||
<PaginationNavigation data-testid="controls">
|
||||
<PaginationPrevious />
|
||||
<PaginationPageJump />
|
||||
<PaginationNext />
|
||||
</PaginationNavigation>
|
||||
<PaginationPageList data-testid="pages" />
|
||||
<PaginationPageSize
|
||||
value={pageSize}
|
||||
options={[10, 25, 50]}
|
||||
onValueChange={onPageSizeChange}
|
||||
/>
|
||||
</PaginationContent>
|
||||
</PaginationRoot>,
|
||||
)
|
||||
|
||||
return {
|
||||
screen,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Pagination primitive', () => {
|
||||
it('renders the Figma-aligned pagination structure with semantic navigation', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '2')
|
||||
await expect.element(screen.getByTestId('content')).toHaveClass('grid', 'grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
|
||||
await expect.element(screen.getByTestId('controls')).toHaveClass('justify-self-start', 'rounded-[10px]', 'bg-background-section-burn')
|
||||
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
|
||||
expect(screen.getByRole('group', { name: 'Items per page' }).element().parentElement).toHaveClass('col-start-3', 'justify-self-end')
|
||||
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveTextContent('2/200')
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toHaveClass('h-7', 'px-2')
|
||||
expect(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).not.toHaveClass('min-w-14')
|
||||
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveAttribute('aria-current', 'page')
|
||||
await expect.element(screen.getByRole('button', { name: 'Page 2, current page' })).toHaveClass('bg-components-button-tertiary-bg')
|
||||
await expect.element(screen.getByText('…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses one-based page changes for previous, next, and page buttons', async () => {
|
||||
const { screen, onPageChange } = await renderPagination({ page: 4 })
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Previous page' }).element()).click()
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Next page' }).element()).click()
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Go to page 6' }).element()).click()
|
||||
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(1, 3)
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(2, 5)
|
||||
expect(onPageChange).toHaveBeenNthCalledWith(3, 6)
|
||||
})
|
||||
|
||||
it('disables previous at the first page', async () => {
|
||||
const { screen } = await renderPagination({ page: 1, totalPages: 10 })
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables next at the last page', async () => {
|
||||
const { screen } = await renderPagination({ page: 10, totalPages: 10 })
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('clamps invalid root page values without exposing invalid state', async () => {
|
||||
const { screen } = await renderPagination({ page: 999, totalPages: 10 })
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '10')
|
||||
await expect.element(screen.getByRole('button', { name: 'Page 10, current page' })).toHaveAttribute('aria-current', 'page')
|
||||
})
|
||||
|
||||
it('switches the page summary into a selected labelled number field', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveValue('2')
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveClass('text-center', 'tabular-nums')
|
||||
expect(input.parentElement?.parentElement?.parentElement).toHaveAttribute('data-page-summary', '2/200')
|
||||
await vi.waitFor(() => {
|
||||
expect(input.selectionStart).toBe(0)
|
||||
expect(input.selectionEnd).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('returns to the summary button when the page input loses focus', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()).blur()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('commits the page input editing mode with Enter', async () => {
|
||||
const { screen } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels the page input editing mode with Escape', async () => {
|
||||
const { screen, onPageChange } = await renderPagination()
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' }).element()).click()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toBeInTheDocument()
|
||||
const input = asHTMLElement(screen.getByRole('textbox', { name: 'Page number' }).element()) as HTMLInputElement
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
|
||||
const summaryButton = screen.getByRole('button', { name: 'Edit page number, current page 2 of 200' })
|
||||
await expect.element(summaryButton).toBeInTheDocument()
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement).toBe(summaryButton.element())
|
||||
})
|
||||
expect(onPageChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses Base UI ToggleGroup semantics for page size', async () => {
|
||||
const { screen, onPageSizeChange } = await renderPagination()
|
||||
|
||||
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toHaveClass('bg-components-segmented-control-bg-normal')
|
||||
await expect.element(screen.getByText('Items per page')).toHaveClass('opacity-0', 'group-hover/page-size:opacity-100', 'group-focus-within/page-size:opacity-100')
|
||||
await expect.element(screen.getByRole('button', { name: '25' })).toHaveAttribute('aria-pressed', 'true')
|
||||
await expect.element(screen.getByRole('button', { name: '25' })).toHaveClass('data-pressed:text-text-primary')
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: '50' }).element()).click()
|
||||
|
||||
expect(onPageSizeChange).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('renders the complete pagination bar with optional page size controls', async () => {
|
||||
const onPageSizeChange = vi.fn()
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={10}
|
||||
onPageChange={vi.fn()}
|
||||
pageSize={{
|
||||
value: 25,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: onPageSizeChange,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Edit page number, current page 2 of 10' })).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('group', { name: 'Items per page' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses a localized action label for editing the page number', async () => {
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={10}
|
||||
onPageChange={vi.fn()}
|
||||
labels={{
|
||||
editPageNumber: (page, totalPages) => `Change page, current page ${page} of ${totalPages}`,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Change page, current page 2 of 10' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps facade page numbers centered when page size controls are omitted', async () => {
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={2}
|
||||
totalPages={10}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument()
|
||||
expect(screen.container.querySelector('nav[aria-label="Pagination"] > div')).toHaveClass('grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]')
|
||||
await expect.element(screen.getByRole('list')).toHaveClass('col-start-2', 'justify-self-center')
|
||||
})
|
||||
|
||||
it('does not expose invalid page controls when there are no pages', async () => {
|
||||
const screen = await render(
|
||||
<Pagination
|
||||
page={1}
|
||||
totalPages={0}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.container.querySelector('nav[aria-label="Pagination"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits compound page jump and page list content for empty pagination state', async () => {
|
||||
const { screen } = await renderPagination({ page: 1, totalPages: 0 })
|
||||
|
||||
await expect.element(screen.getByRole('navigation', { name: 'Pagination' })).toHaveAttribute('data-page', '1')
|
||||
expect(screen.container.querySelector('button[aria-label*="current page 1 of 0"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('button[aria-label="Previous page"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('button[aria-label="Next page"]')).not.toBeInTheDocument()
|
||||
expect(screen.container.querySelector('ol')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('allows custom page rendering while keeping the shared context', async () => {
|
||||
const onPageChange = vi.fn()
|
||||
const screen = await render(
|
||||
<PaginationRoot page={3} totalPages={5} onPageChange={onPageChange}>
|
||||
<ol>
|
||||
<li>
|
||||
<PaginationPage page={4} className="custom-page">
|
||||
Four
|
||||
</PaginationPage>
|
||||
</li>
|
||||
</ol>
|
||||
</PaginationRoot>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Go to page 4' }).element()).click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'Go to page 4' })).toHaveClass('custom-page')
|
||||
expect(onPageChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('renders a non-interactive loading skeleton', async () => {
|
||||
const screen = await render(<PaginationSkeleton data-testid="skeleton" />)
|
||||
|
||||
await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true')
|
||||
await expect.element(screen.getByTestId('skeleton')).toHaveClass('select-none')
|
||||
})
|
||||
})
|
||||
93
packages/dify-ui/src/pagination/index.stories.tsx
Normal file
93
packages/dify-ui/src/pagination/index.stories.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationSkeleton,
|
||||
} from '.'
|
||||
|
||||
function PaginationExample({
|
||||
initialPage = 2,
|
||||
initialPageSize = 25,
|
||||
totalPages = 200,
|
||||
}: {
|
||||
initialPage?: number
|
||||
initialPageSize?: number
|
||||
totalPages?: number
|
||||
}) {
|
||||
const [page, setPage] = useState(initialPage)
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
pageSize={{
|
||||
value: pageSize,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setPageSize,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationDemo(props: ComponentProps<typeof PaginationExample>) {
|
||||
return (
|
||||
<div className="w-236 max-w-full bg-components-panel-bg px-16 py-10">
|
||||
<PaginationExample {...props} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DesignSpecDemo() {
|
||||
return (
|
||||
<div className="flex w-236 max-w-full flex-col gap-6 bg-components-panel-bg px-16 py-10">
|
||||
<PaginationExample />
|
||||
<PaginationExample initialPage={2} initialPageSize={25} />
|
||||
<PaginationExample initialPage={2} initialPageSize={25} />
|
||||
<PaginationExample initialPage={2} initialPageSize={25} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/Pagination',
|
||||
component: PaginationDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound pagination primitive for list navigation. It combines semantic page buttons, a NumberField-backed page jump summary, and a ToggleGroup-backed page-size selector.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialPage: 2,
|
||||
initialPageSize: 25,
|
||||
totalPages: 200,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PaginationDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PaginationDemo />,
|
||||
}
|
||||
|
||||
export const DesignSpec: Story = {
|
||||
render: () => <DesignSpecDemo />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Pagination rows with default, hover-like, focused, page-size, and skeleton examples.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => <PaginationSkeleton />,
|
||||
}
|
||||
651
packages/dify-ui/src/pagination/index.tsx
Normal file
651
packages/dify-ui/src/pagination/index.tsx
Normal file
@ -0,0 +1,651 @@
|
||||
'use client'
|
||||
|
||||
import type { Button as BaseButtonNS } from '@base-ui/react/button'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button as BaseButton } from '@base-ui/react/button'
|
||||
import { mergeProps } from '@base-ui/react/merge-props'
|
||||
import { useRender } from '@base-ui/react/use-render'
|
||||
import { createContext, useContext, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '../cn'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldGroup,
|
||||
NumberFieldInput,
|
||||
} from '../number-field'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '../toggle-group'
|
||||
|
||||
type PageItem = number | 'ellipsis-start' | 'ellipsis-end'
|
||||
|
||||
type PaginationContextValue = {
|
||||
page: number
|
||||
totalPages: number
|
||||
hasPages: boolean
|
||||
disabled: boolean
|
||||
onPageChange: (page: number) => void
|
||||
items: PageItem[]
|
||||
}
|
||||
|
||||
const PaginationContext = createContext<PaginationContextValue | null>(null)
|
||||
|
||||
function usePaginationContext(component: string) {
|
||||
const context = useContext(PaginationContext)
|
||||
|
||||
if (!context)
|
||||
throw new Error(`${component} must be used inside PaginationRoot.`)
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function clampPage(page: number, totalPages: number) {
|
||||
if (!Number.isFinite(page))
|
||||
return 1
|
||||
|
||||
return Math.min(Math.max(Math.trunc(page), 1), Math.max(totalPages, 1))
|
||||
}
|
||||
|
||||
function range(start: number, end: number) {
|
||||
if (end < start)
|
||||
return []
|
||||
|
||||
return Array.from({ length: end - start + 1 }, (_, index) => start + index)
|
||||
}
|
||||
|
||||
type GetPageItemsOptions = {
|
||||
page: number
|
||||
totalPages: number
|
||||
siblingCount: number
|
||||
boundaryCount: number
|
||||
visiblePageCount: number
|
||||
}
|
||||
|
||||
function getPageItems({
|
||||
page,
|
||||
totalPages,
|
||||
siblingCount,
|
||||
boundaryCount,
|
||||
visiblePageCount,
|
||||
}: GetPageItemsOptions): PageItem[] {
|
||||
if (totalPages <= 0)
|
||||
return []
|
||||
|
||||
const normalizedPage = clampPage(page, totalPages)
|
||||
const normalizedBoundaryCount = Math.max(Math.trunc(boundaryCount), 1)
|
||||
const normalizedSiblingCount = Math.max(Math.trunc(siblingCount), 0)
|
||||
const windowSize = Math.max(
|
||||
Math.trunc(visiblePageCount),
|
||||
normalizedSiblingCount * 2 + 1,
|
||||
)
|
||||
|
||||
if (totalPages <= windowSize + normalizedBoundaryCount)
|
||||
return range(1, totalPages)
|
||||
|
||||
const nearStartEnd = windowSize
|
||||
const nearEndStart = totalPages - windowSize + 1
|
||||
const middleStart = Math.max(
|
||||
normalizedBoundaryCount + 1,
|
||||
normalizedPage - normalizedSiblingCount,
|
||||
)
|
||||
const middleEnd = Math.min(
|
||||
totalPages - normalizedBoundaryCount,
|
||||
normalizedPage + normalizedSiblingCount,
|
||||
)
|
||||
|
||||
const windowPages = normalizedPage <= nearStartEnd - normalizedSiblingCount
|
||||
? range(1, nearStartEnd)
|
||||
: normalizedPage >= nearEndStart + normalizedSiblingCount
|
||||
? range(nearEndStart, totalPages)
|
||||
: range(middleStart, middleEnd)
|
||||
|
||||
const pageSet = new Set([
|
||||
...range(1, normalizedBoundaryCount),
|
||||
...windowPages,
|
||||
...range(totalPages - normalizedBoundaryCount + 1, totalPages),
|
||||
])
|
||||
const pages = Array.from(pageSet)
|
||||
.filter(item => item >= 1 && item <= totalPages)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
return pages.reduce<PageItem[]>((items, item, index) => {
|
||||
const previous = pages[index - 1]
|
||||
|
||||
if (previous && item - previous === 2)
|
||||
items.push(previous + 1)
|
||||
else if (previous && item - previous > 2)
|
||||
items.push(item < normalizedPage ? 'ellipsis-start' : 'ellipsis-end')
|
||||
|
||||
items.push(item)
|
||||
return items
|
||||
}, [])
|
||||
}
|
||||
|
||||
type PaginationRootState = {
|
||||
page: number
|
||||
totalPages: number
|
||||
hasPages: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type PaginationRootProps = Omit<
|
||||
useRender.ComponentProps<'nav', PaginationRootState>,
|
||||
'onChange'
|
||||
> & {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
siblingCount?: number
|
||||
boundaryCount?: number
|
||||
visiblePageCount?: number
|
||||
}
|
||||
|
||||
export function PaginationRoot({
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
siblingCount = 1,
|
||||
boundaryCount = 1,
|
||||
visiblePageCount = 8,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: PaginationRootProps) {
|
||||
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
|
||||
const normalizedPage = clampPage(page, normalizedTotalPages)
|
||||
const hasPages = normalizedTotalPages > 0
|
||||
const disabled = normalizedTotalPages <= 1
|
||||
const items = useMemo(() => getPageItems({
|
||||
page: normalizedPage,
|
||||
totalPages: normalizedTotalPages,
|
||||
siblingCount,
|
||||
boundaryCount,
|
||||
visiblePageCount,
|
||||
}), [
|
||||
boundaryCount,
|
||||
normalizedPage,
|
||||
normalizedTotalPages,
|
||||
siblingCount,
|
||||
visiblePageCount,
|
||||
])
|
||||
|
||||
const context = useMemo<PaginationContextValue>(() => ({
|
||||
page: normalizedPage,
|
||||
totalPages: normalizedTotalPages,
|
||||
hasPages,
|
||||
disabled,
|
||||
onPageChange: nextPage => onPageChange(clampPage(nextPage, normalizedTotalPages)),
|
||||
items,
|
||||
}), [disabled, hasPages, items, normalizedPage, normalizedTotalPages, onPageChange])
|
||||
|
||||
const defaultProps: useRender.ElementProps<'nav'> = {
|
||||
'aria-label': 'Pagination',
|
||||
'className': 'flex w-full min-w-0 items-center justify-between px-6 py-3 select-none',
|
||||
'children': (
|
||||
<PaginationContext.Provider value={context}>
|
||||
{children}
|
||||
</PaginationContext.Provider>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'nav',
|
||||
render,
|
||||
state: {
|
||||
page: normalizedPage,
|
||||
totalPages: normalizedTotalPages,
|
||||
hasPages,
|
||||
disabled,
|
||||
},
|
||||
props: mergeProps<'nav'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type PaginationNavigationProps = useRender.ComponentProps<'div'>
|
||||
|
||||
export type PaginationContentProps = useRender.ComponentProps<'div'>
|
||||
|
||||
export function PaginationContent({
|
||||
render,
|
||||
...props
|
||||
}: PaginationContentProps) {
|
||||
const defaultProps: useRender.ElementProps<'div'> = {
|
||||
className: 'grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2',
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
render,
|
||||
props: mergeProps<'div'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export function PaginationNavigation({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationNavigationProps) {
|
||||
const defaultProps: useRender.ElementProps<'div'> = {
|
||||
className: cn('flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
render,
|
||||
props: mergeProps<'div'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
type PaginationButtonProps = Omit<BaseButtonNS.Props, 'children'> & {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const paginationArrowButtonClassName = [
|
||||
'inline-flex size-7 shrink-0 touch-manipulation items-center justify-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text shadow-xs outline-hidden backdrop-blur-[10px] transition-[background-color,border-color,color,box-shadow]',
|
||||
'hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
|
||||
'focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
|
||||
'disabled:cursor-not-allowed disabled:border-components-button-secondary-border-disabled disabled:bg-components-button-secondary-bg-disabled disabled:text-components-button-secondary-text-disabled disabled:shadow-none',
|
||||
'motion-reduce:transition-none',
|
||||
]
|
||||
|
||||
export function PaginationPrevious({
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationButtonProps) {
|
||||
const pagination = usePaginationContext('PaginationPrevious')
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
const disabled = props.disabled || pagination.page <= 1 || pagination.disabled
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? 'Previous page'}
|
||||
className={cn(paginationArrowButtonClassName, className)}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented && !disabled)
|
||||
pagination.onPageChange(pagination.page - 1)
|
||||
}}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-left-line size-4" aria-hidden="true" />}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export function PaginationNext({
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationButtonProps) {
|
||||
const pagination = usePaginationContext('PaginationNext')
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
const disabled = props.disabled || pagination.page >= pagination.totalPages || pagination.disabled
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? 'Next page'}
|
||||
className={cn(paginationArrowButtonClassName, className)}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented && !disabled)
|
||||
pagination.onPageChange(pagination.page + 1)
|
||||
}}
|
||||
>
|
||||
{children ?? <span className="i-ri-arrow-right-line size-4" aria-hidden="true" />}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationPageJumpProps = Omit<BaseButtonNS.Props, 'children'> & {
|
||||
inputLabel?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PaginationPageJump({
|
||||
className,
|
||||
inputLabel = 'Page number',
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationPageJumpProps) {
|
||||
const pagination = usePaginationContext('PaginationPageJump')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const summaryButtonRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<span
|
||||
data-page-summary={`${pagination.page}/${pagination.totalPages}`}
|
||||
className="inline-grid h-7 system-xs-medium tabular-nums after:invisible after:col-start-1 after:row-start-1 after:py-1.5 after:pr-3 after:pl-2 after:content-[attr(data-page-summary)]"
|
||||
>
|
||||
<NumberField
|
||||
key={pagination.page}
|
||||
className="col-start-1 row-start-1 w-full"
|
||||
defaultValue={pagination.page}
|
||||
min={1}
|
||||
max={Math.max(pagination.totalPages, 1)}
|
||||
onValueCommitted={(value) => {
|
||||
if (value !== null)
|
||||
pagination.onPageChange(value)
|
||||
|
||||
setEditing(false)
|
||||
}}
|
||||
>
|
||||
<NumberFieldGroup
|
||||
className="h-7 w-full min-w-0 rounded-lg border-[0.5px] border-components-input-border-active bg-components-input-bg-active shadow-xs"
|
||||
>
|
||||
<NumberFieldInput
|
||||
aria-label={inputLabel}
|
||||
autoFocus
|
||||
className="px-2 py-1.5 text-center system-xs-medium tabular-nums"
|
||||
onBlur={() => requestAnimationFrame(() => setEditing(false))}
|
||||
onFocus={(event) => {
|
||||
const input = event.currentTarget
|
||||
requestAnimationFrame(() => input.select())
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.currentTarget.blur()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setEditing(false)
|
||||
requestAnimationFrame(() => summaryButtonRef.current?.focus())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
ref={summaryButtonRef}
|
||||
type="button"
|
||||
aria-label={ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`}
|
||||
className={cn(
|
||||
'inline-flex h-7 touch-manipulation items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 system-xs-medium tabular-nums text-text-secondary outline-hidden transition-colors hover:cursor-text hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-components-input-border-hover motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented)
|
||||
setEditing(true)
|
||||
}}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span>{pagination.page}</span>
|
||||
<span className="text-text-quaternary">/</span>
|
||||
<span>{pagination.totalPages}</span>
|
||||
</>
|
||||
)}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationPageListProps = useRender.ComponentProps<'ol'>
|
||||
|
||||
export function PaginationPageList({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: PaginationPageListProps) {
|
||||
const pagination = usePaginationContext('PaginationPageList')
|
||||
|
||||
if (!pagination.hasPages)
|
||||
return null
|
||||
|
||||
const defaultProps: useRender.ElementProps<'ol'> = {
|
||||
className: cn('col-start-2 flex min-w-0 list-none items-center justify-self-center gap-0.5', className),
|
||||
children: pagination.items.map(item => (
|
||||
<li key={item}>
|
||||
{typeof item === 'number'
|
||||
? <PaginationPage page={item} />
|
||||
: <PaginationEllipsis />}
|
||||
</li>
|
||||
)),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'ol',
|
||||
render,
|
||||
props: mergeProps<'ol'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type PaginationPageProps = Omit<BaseButtonNS.Props, 'children'> & {
|
||||
page: number
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function PaginationPage({
|
||||
page,
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: PaginationPageProps) {
|
||||
const pagination = usePaginationContext('PaginationPage')
|
||||
const current = page === pagination.page
|
||||
|
||||
return (
|
||||
<BaseButton
|
||||
{...props}
|
||||
type="button"
|
||||
aria-current={current ? 'page' : undefined}
|
||||
aria-label={ariaLabel ?? (current ? `Page ${page}, current page` : `Go to page ${page}`)}
|
||||
className={cn(
|
||||
'inline-flex h-8 min-w-8 touch-manipulation items-center justify-center rounded-lg px-1 py-2 system-sm-medium tabular-nums text-text-tertiary outline-hidden transition-colors hover:bg-components-button-ghost-bg-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-components-input-border-hover',
|
||||
current && 'bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover',
|
||||
'motion-reduce:transition-none',
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
props.onClick?.(event)
|
||||
|
||||
if (!event.defaultPrevented)
|
||||
pagination.onPageChange(page)
|
||||
}}
|
||||
>
|
||||
{children ?? page}
|
||||
</BaseButton>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationEllipsisProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function PaginationEllipsis({
|
||||
render,
|
||||
...props
|
||||
}: PaginationEllipsisProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
'aria-hidden': true,
|
||||
'className': 'flex size-8 items-center justify-center px-1 py-2 system-sm-medium text-text-tertiary',
|
||||
'children': '…',
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type PaginationPageSizeProps<Value extends number = number> = {
|
||||
'value': Value
|
||||
'options': readonly Value[]
|
||||
'onValueChange': (value: Value) => void
|
||||
'label'?: ReactNode
|
||||
'aria-label'?: string
|
||||
'className'?: string
|
||||
}
|
||||
|
||||
export function PaginationPageSize<Value extends number = number>({
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
label = 'Items per page',
|
||||
'aria-label': ariaLabel = 'Items per page',
|
||||
className,
|
||||
}: PaginationPageSizeProps<Value>) {
|
||||
return (
|
||||
<div className={cn('group/page-size col-start-3 flex shrink-0 items-center justify-end justify-self-end gap-2', className)}>
|
||||
<div className="w-13 shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary opacity-0 transition-opacity group-hover/page-size:opacity-100 group-focus-within/page-size:opacity-100 motion-reduce:transition-none">
|
||||
{label}
|
||||
</div>
|
||||
<ToggleGroup
|
||||
value={[String(value)]}
|
||||
aria-label={ariaLabel}
|
||||
onValueChange={(nextValue) => {
|
||||
const [selectedValue] = nextValue
|
||||
|
||||
if (!selectedValue)
|
||||
return
|
||||
|
||||
const selectedOption = options.find(option => String(option) === selectedValue)
|
||||
|
||||
if (selectedOption !== undefined)
|
||||
onValueChange(selectedOption)
|
||||
}}
|
||||
>
|
||||
{options.map(option => (
|
||||
<ToggleGroupItem
|
||||
key={option}
|
||||
value={String(option)}
|
||||
className="min-w-9 data-pressed:text-text-primary"
|
||||
>
|
||||
{option}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationLabels = {
|
||||
previous?: string
|
||||
next?: string
|
||||
editPageNumber?: (page: number, totalPages: number) => string
|
||||
pageNumberInput?: string
|
||||
}
|
||||
|
||||
export type PaginationPageSizeConfig<Value extends number = number> = {
|
||||
value: Value
|
||||
options: readonly Value[]
|
||||
onValueChange: (value: Value) => void
|
||||
label?: ReactNode
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export type PaginationProps<Value extends number = number> = Omit<PaginationRootProps, 'children'> & {
|
||||
labels?: PaginationLabels
|
||||
pageSize?: PaginationPageSizeConfig<Value>
|
||||
}
|
||||
|
||||
export function Pagination<Value extends number = number>({
|
||||
labels,
|
||||
pageSize,
|
||||
page,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
...props
|
||||
}: PaginationProps<Value>) {
|
||||
const normalizedTotalPages = Math.max(Math.trunc(totalPages), 0)
|
||||
const normalizedPage = clampPage(page, normalizedTotalPages)
|
||||
const editPageNumber = labels?.editPageNumber?.(normalizedPage, normalizedTotalPages)
|
||||
|
||||
if (normalizedTotalPages <= 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<PaginationRoot
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
{...props}
|
||||
>
|
||||
<PaginationContent>
|
||||
<PaginationNavigation>
|
||||
<PaginationPrevious aria-label={labels?.previous} />
|
||||
<PaginationPageJump
|
||||
aria-label={editPageNumber}
|
||||
inputLabel={labels?.pageNumberInput}
|
||||
/>
|
||||
<PaginationNext aria-label={labels?.next} />
|
||||
</PaginationNavigation>
|
||||
<PaginationPageList />
|
||||
{pageSize && (
|
||||
<PaginationPageSize
|
||||
value={pageSize.value}
|
||||
options={pageSize.options}
|
||||
onValueChange={pageSize.onValueChange}
|
||||
label={pageSize.label}
|
||||
aria-label={pageSize.ariaLabel}
|
||||
/>
|
||||
)}
|
||||
</PaginationContent>
|
||||
</PaginationRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export type PaginationSkeletonProps = useRender.ComponentProps<'div'>
|
||||
|
||||
export function PaginationSkeleton({
|
||||
render,
|
||||
...props
|
||||
}: PaginationSkeletonProps) {
|
||||
const defaultProps: useRender.ElementProps<'div'> = {
|
||||
'aria-hidden': true,
|
||||
'className': 'flex w-full min-w-0 items-center justify-between px-6 py-3 select-none',
|
||||
'children': (
|
||||
<div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2">
|
||||
<div className="flex shrink-0 items-center justify-self-start gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
|
||||
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="h-7 min-w-14 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
<div className="size-7 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="col-start-2 flex items-center justify-self-center gap-0.5">
|
||||
{range(1, 8).map(item => (
|
||||
<div key={item} className="h-8 min-w-8 animate-pulse rounded-lg bg-state-base-hover motion-reduce:animate-none" />
|
||||
))}
|
||||
</div>
|
||||
<div className="col-start-3 flex shrink-0 items-center justify-self-end">
|
||||
<div className="h-8 w-28 animate-pulse rounded-[10px] bg-state-base-hover motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'div',
|
||||
render,
|
||||
props: mergeProps<'div'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
@ -207,16 +207,6 @@ describe('Select wrappers', () => {
|
||||
|
||||
expect(screen.getByRole('combobox', { name: 'city select' }).element().className).toContain('data-popup-open:bg-state-base-hover-alt')
|
||||
})
|
||||
|
||||
it('should include keyboard focus ring classes', async () => {
|
||||
const screen = await renderOpenSelect()
|
||||
|
||||
await expect.element(screen.getByRole('combobox', { name: 'city select' })).toHaveClass(
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-components-input-border-active',
|
||||
'focus-visible:ring-inset',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SelectContent', () => {
|
||||
|
||||
@ -24,7 +24,6 @@ const selectTriggerVariants = cva(
|
||||
[
|
||||
'group flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
|
||||
'focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
|
||||
@ -49,19 +49,6 @@ describe('Switch', () => {
|
||||
await expect.element(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should work in uncontrolled mode with defaultChecked prop', async () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
const screen = await render(<Switch defaultChecked={false} onCheckedChange={onCheckedChange} />)
|
||||
const switchElement = screen.getByRole('switch')
|
||||
|
||||
await expect.element(switchElement).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
asHTMLElement(switchElement.element()).click()
|
||||
|
||||
expect(onCheckedChange).toHaveBeenCalledWith(true)
|
||||
await expect.element(switchElement).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('should not call onCheckedChange when disabled', async () => {
|
||||
const onCheckedChange = vi.fn()
|
||||
const screen = await render(<Switch checked={false} disabled onCheckedChange={onCheckedChange} />)
|
||||
@ -155,24 +142,6 @@ describe('Switch', () => {
|
||||
expect(screen.container.querySelector('span[aria-hidden="true"] i')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use checked data attributes to position spinner', async () => {
|
||||
const screen = await render(<Switch checked={false} loading size="md" />)
|
||||
const spinner = screen.container.querySelector('span[aria-hidden="true"]')
|
||||
|
||||
expect(spinner).toHaveClass(
|
||||
'left-[calc(50%+6px)]',
|
||||
'group-data-checked:left-[calc(50%-6px)]',
|
||||
)
|
||||
|
||||
await screen.rerender(<Switch checked={true} loading size="md" />)
|
||||
|
||||
await expect.element(screen.getByRole('switch')).toHaveAttribute('data-checked', '')
|
||||
expect(screen.container.querySelector('span[aria-hidden="true"]')).toHaveClass(
|
||||
'left-[calc(50%+6px)]',
|
||||
'group-data-checked:left-[calc(50%-6px)]',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not show spinner for xs and sm sizes', async () => {
|
||||
const screen = await render(<Switch checked={false} loading size="xs" />)
|
||||
expect(screen.container.querySelector('span[aria-hidden="true"] i')).not.toBeInTheDocument()
|
||||
|
||||
@ -2,11 +2,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Switch, SwitchSkeleton } from '.'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Switch',
|
||||
@ -15,7 +10,7 @@ const meta = {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Toggle switch primitive with controlled and uncontrolled state support, loading state, and skeleton placeholder.',
|
||||
component: 'Toggle switch built on Base UI with CVA variants, Figma-aligned design tokens, loading spinner, and skeleton placeholder. Import `Switch` and `SwitchSkeleton` from `@langgenius/dify-ui/switch`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -47,27 +42,20 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type SwitchDemoProps = Partial<Omit<ComponentProps<typeof Switch>, 'checked' | 'defaultChecked' | 'onCheckedChange'>> & {
|
||||
checked?: boolean
|
||||
}
|
||||
|
||||
const SwitchDemo = (args: SwitchDemoProps) => {
|
||||
const SwitchDemo = (args: Partial<ComponentProps<typeof Switch>>) => {
|
||||
const [enabled, setEnabled] = useState(args.checked ?? false)
|
||||
|
||||
return (
|
||||
<FieldRoot name="autoRetry" className="w-72">
|
||||
<FieldLabel className="flex items-center justify-between gap-3">
|
||||
<span>Enable auto retry</span>
|
||||
<Switch
|
||||
{...args}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{enabled ? 'Failures will retry automatically.' : 'Failures require manual retry.'}
|
||||
</FieldDescription>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Switch
|
||||
{...args}
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{enabled ? 'On' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -128,24 +116,24 @@ const AllStatesDemo = () => {
|
||||
<td className="py-3 font-medium text-gray-900">{size}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} checked={false} onCheckedChange={() => {}} aria-label={`${size} unchecked switch`} />
|
||||
<Switch size={size} checked={true} onCheckedChange={() => {}} aria-label={`${size} checked switch`} />
|
||||
<Switch size={size} checked={false} onCheckedChange={() => {}} />
|
||||
<Switch size={size} checked={true} onCheckedChange={() => {}} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} checked={false} disabled aria-label={`${size} disabled unchecked switch`} />
|
||||
<Switch size={size} checked={true} disabled aria-label={`${size} disabled checked switch`} />
|
||||
<Switch size={size} checked={false} disabled />
|
||||
<Switch size={size} checked={true} disabled />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-2">
|
||||
<Switch size={size} checked={false} loading aria-label={`${size} loading unchecked switch`} />
|
||||
<Switch size={size} checked={true} loading aria-label={`${size} loading checked switch`} />
|
||||
<Switch size={size} checked={false} loading />
|
||||
<Switch size={size} checked={true} loading />
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SwitchSkeleton size={size} aria-hidden="true" />
|
||||
<SwitchSkeleton size={size} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -160,7 +148,7 @@ export const AllStates: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Variant matrix for switch sizes and states.',
|
||||
story: 'Complete variant matrix: all sizes × all states, matching Figma design spec (node 2144:1210).',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -176,30 +164,22 @@ const SizeComparisonDemo = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<FieldRoot name="extraSmallSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
|
||||
Extra Small (xs) - 14x10
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="smallSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
|
||||
Small (sm) - 20x12
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
|
||||
Regular (md) - 28x16
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="largeSwitch">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
|
||||
Large (lg) - 36x20
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={states.xs} onCheckedChange={v => setStates({ ...states, xs: v })} />
|
||||
<span className="text-sm text-gray-700">Extra Small (xs) — 14×10</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={states.sm} onCheckedChange={v => setStates({ ...states, sm: v })} />
|
||||
<span className="text-sm text-gray-700">Small (sm) — 20×12</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" checked={states.md} onCheckedChange={v => setStates({ ...states, md: v })} />
|
||||
<span className="text-sm text-gray-700">Regular (md) — 28×16</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={states.lg} onCheckedChange={v => setStates({ ...states, lg: v })} />
|
||||
<span className="text-sm text-gray-700">Large (lg) — 36×20</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -220,42 +200,30 @@ const LoadingDemo = () => {
|
||||
{loading ? 'Stop Loading' : 'Start Loading'}
|
||||
</button>
|
||||
<div className="space-y-3">
|
||||
<FieldRoot name="largeUncheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={false} loading={loading} />
|
||||
Large unchecked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="largeCheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={true} loading={loading} />
|
||||
Large checked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularUncheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="md" checked={false} loading={loading} />
|
||||
Regular unchecked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="regularCheckedLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="md" checked={true} loading={loading} />
|
||||
Regular checked
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="smallLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={false} loading={loading} />
|
||||
Small
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="extraSmallLoading">
|
||||
<FieldLabel className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={false} loading={loading} />
|
||||
Extra Small
|
||||
</FieldLabel>
|
||||
</FieldRoot>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Large unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="lg" checked={true} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Large checked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Regular unchecked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="md" checked={true} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Regular checked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="sm" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Small (no spinner)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch size="xs" checked={false} loading={loading} />
|
||||
<span className="text-sm text-gray-700">Extra Small (no spinner)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -266,7 +234,7 @@ export const Loading: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Loading state disables interaction and shows a spinner for md and lg sizes.',
|
||||
story: 'Loading state disables interaction and shows a spinning icon (i-ri-loader-2-line) for md/lg sizes. Spinner position mirrors the knob: appears on the opposite side of the checked state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -274,76 +242,61 @@ export const Loading: Story = {
|
||||
|
||||
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function useMockAutoRetrySettingQuery() {
|
||||
const MutationLoadingDemo = () => {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
return {
|
||||
data: {
|
||||
enabled,
|
||||
},
|
||||
setData: setEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
function useMockUpdateAutoRetrySettingMutation({
|
||||
onSuccess,
|
||||
}: {
|
||||
onSuccess: (enabled: boolean) => void
|
||||
}) {
|
||||
const [requestCount, setRequestCount] = useState(0)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const mutate = (nextValue: boolean) => {
|
||||
const handleChange = (nextValue: boolean) => {
|
||||
if (isPending)
|
||||
return
|
||||
|
||||
startTransition(async () => {
|
||||
setRequestCount(current => current + 1)
|
||||
await wait(1200)
|
||||
onSuccess(nextValue)
|
||||
setEnabled(nextValue)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
requestCount,
|
||||
isPending,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
const MutationLoadingDemo = () => {
|
||||
const autoRetrySetting = useMockAutoRetrySettingQuery()
|
||||
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
|
||||
onSuccess: autoRetrySetting.setData,
|
||||
})
|
||||
const statusText = updateAutoRetrySetting.isPending
|
||||
? 'Saving changes...'
|
||||
: autoRetrySetting.data.enabled
|
||||
? 'Auto retry is enabled.'
|
||||
: 'Auto retry is disabled.'
|
||||
|
||||
return (
|
||||
<div className="grid w-90 gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
|
||||
<FieldRoot name="autoRetry">
|
||||
<FieldLabel className="flex items-center justify-between gap-4">
|
||||
<span className="system-sm-medium text-text-secondary">Enable auto retry</span>
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={autoRetrySetting.data.enabled}
|
||||
loading={updateAutoRetrySetting.isPending}
|
||||
onCheckedChange={updateAutoRetrySetting.mutate}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<FieldDescription>Retry failed workflow runs without manual intervention.</FieldDescription>
|
||||
</FieldRoot>
|
||||
<div className="w-[340px] space-y-4 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-text-primary">Mutation Loading Guard</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
Click once to start a simulated mutate call. While the request is pending, the switch enters
|
||||
{' '}
|
||||
<code className="rounded-sm bg-state-base-hover px-1 py-0.5 text-[11px]">loading</code>
|
||||
{' '}
|
||||
and rejects duplicate clicks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-text-tertiary" aria-live="polite">
|
||||
{statusText}
|
||||
{' '}
|
||||
Save attempts:
|
||||
{' '}
|
||||
{updateAutoRetrySetting.requestCount}
|
||||
</span>
|
||||
<div className="flex items-center justify-between rounded-xl border border-components-panel-border-subtle bg-background-default-dodge px-3 py-2 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-text-primary">Enable Auto Retry</p>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{isPending ? 'Saving…' : enabled ? 'Saved as on' : 'Saved as off'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
size="lg"
|
||||
checked={enabled}
|
||||
loading={isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="Enable Auto Retry"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-text-tertiary">
|
||||
<div className="rounded-lg bg-state-base-hover px-3 py-2">
|
||||
<div className="font-medium text-text-secondary">Committed Value</div>
|
||||
<div>{enabled ? 'On' : 'Off'}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-state-base-hover px-3 py-2">
|
||||
<div className="font-medium text-text-secondary">Mutate Count</div>
|
||||
<div>{requestCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -353,7 +306,7 @@ export const MutationLoadingGuard: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Controlled switch that enters loading while the change is saved.',
|
||||
story: 'Simulates a controlled switch backed by an async mutate call. The component keeps its previous committed value, sets `loading` during the request, and blocks duplicate clicks until the mutation resolves.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -362,19 +315,19 @@ export const MutationLoadingGuard: Story = {
|
||||
const SkeletonDemo = () => (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="xs" aria-hidden="true" />
|
||||
<SwitchSkeleton size="xs" />
|
||||
<span className="text-sm text-gray-700">Extra Small skeleton</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="sm" aria-hidden="true" />
|
||||
<SwitchSkeleton size="sm" />
|
||||
<span className="text-sm text-gray-700">Small skeleton</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="md" aria-hidden="true" />
|
||||
<SwitchSkeleton size="md" />
|
||||
<span className="text-sm text-gray-700">Regular skeleton</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SwitchSkeleton size="lg" aria-hidden="true" />
|
||||
<SwitchSkeleton size="lg" />
|
||||
<span className="text-sm text-gray-700">Large skeleton</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -385,7 +338,7 @@ export const Skeleton: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Non-interactive placeholders for switch loading layouts.',
|
||||
story: '`SwitchSkeleton` renders a non-interactive placeholder with `bg-text-quaternary opacity-20`. Exported from `@langgenius/dify-ui/switch` alongside `Switch`.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -45,34 +45,26 @@ const switchThumbVariants = cva(
|
||||
|
||||
export type SwitchSize = NonNullable<VariantProps<typeof switchRootVariants>['size']>
|
||||
|
||||
const switchSpinnerVariants = cva(
|
||||
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
md: 'size-2 left-[calc(50%+6px)] group-data-checked:left-[calc(50%-6px)]',
|
||||
lg: 'size-2.5 left-[calc(50%+8px)] group-data-checked:left-[calc(50%-8px)]',
|
||||
},
|
||||
},
|
||||
const spinnerSizeConfig: Partial<Record<SwitchSize, {
|
||||
icon: string
|
||||
uncheckedPosition: string
|
||||
checkedPosition: string
|
||||
}>> = {
|
||||
md: {
|
||||
icon: 'size-2',
|
||||
uncheckedPosition: 'left-[calc(50%+6px)]',
|
||||
checkedPosition: 'left-[calc(50%-6px)]',
|
||||
},
|
||||
lg: {
|
||||
icon: 'size-2.5',
|
||||
uncheckedPosition: 'left-[calc(50%+8px)]',
|
||||
checkedPosition: 'left-[calc(50%-8px)]',
|
||||
},
|
||||
)
|
||||
|
||||
type ControlledSwitchProps = {
|
||||
checked: boolean
|
||||
defaultChecked?: never
|
||||
}
|
||||
|
||||
type UncontrolledSwitchProps = {
|
||||
checked?: never
|
||||
defaultChecked?: boolean
|
||||
}
|
||||
|
||||
type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
|
||||
|
||||
export type SwitchProps
|
||||
= Omit<BaseSwitchNS.Root.Props, 'checked' | 'defaultChecked' | 'className' | 'size' | 'onCheckedChange'>
|
||||
= Omit<BaseSwitchNS.Root.Props, 'className' | 'size' | 'onCheckedChange'>
|
||||
& VariantProps<typeof switchRootVariants>
|
||||
& SwitchControlProps
|
||||
& {
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
loading?: boolean
|
||||
@ -89,6 +81,7 @@ export function Switch({
|
||||
...props
|
||||
}: SwitchProps) {
|
||||
const isDisabled = disabled || loading
|
||||
const spinner = loading && size ? spinnerSizeConfig[size] : undefined
|
||||
|
||||
return (
|
||||
<BaseSwitch.Root
|
||||
@ -102,10 +95,14 @@ export function Switch({
|
||||
<BaseSwitch.Thumb
|
||||
className={switchThumbVariants({ size })}
|
||||
/>
|
||||
{loading && (size === 'md' || size === 'lg')
|
||||
{spinner
|
||||
? (
|
||||
<span
|
||||
className={switchSpinnerVariants({ size })}
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
spinner.icon,
|
||||
checked ? spinner.checkedPosition : spinner.uncheckedPosition,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i className="i-ri-loader-2-line size-full animate-spin text-text-tertiary motion-reduce:animate-none" />
|
||||
@ -134,8 +131,11 @@ const switchSkeletonVariants = cva(
|
||||
)
|
||||
|
||||
export type SwitchSkeletonProps
|
||||
= HTMLAttributes<HTMLDivElement>
|
||||
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
|
||||
& VariantProps<typeof switchSkeletonVariants>
|
||||
& {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SwitchSkeleton({
|
||||
size = 'md',
|
||||
|
||||
@ -41,7 +41,6 @@ describe('@langgenius/dify-ui/toast', () => {
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveAttribute('aria-live', 'polite')
|
||||
await expect.element(screen.getByRole('region', { name: 'Notifications' })).toHaveClass('z-60')
|
||||
expect(screen.getByRole('region', { name: 'Notifications' }).element().firstElementChild).toHaveClass('top-4')
|
||||
expect(screen.getByText('Saved').element().closest('[class*="transition-opacity"]')).toHaveClass('motion-reduce:transition-none')
|
||||
expect(screen.getByRole('dialog').element()).not.toHaveClass('outline-hidden')
|
||||
expect(document.body.querySelector('[aria-hidden="true"].i-ri-checkbox-circle-fill')).toBeInTheDocument()
|
||||
expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).toBeInTheDocument()
|
||||
|
||||
@ -171,7 +171,7 @@ function ToastCard({
|
||||
aria-hidden="true"
|
||||
className={cn('absolute -inset-px bg-linear-to-r opacity-40', getToneGradientClasses(toastType))}
|
||||
/>
|
||||
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100 motion-reduce:transition-none">
|
||||
<BaseToast.Content className="relative flex items-start gap-1 overflow-hidden p-3 transition-opacity duration-200 data-behind:opacity-0 data-expanded:opacity-100">
|
||||
<div className="flex shrink-0 items-center justify-center p-0.5">
|
||||
<ToastIcon type={toastType} />
|
||||
</div>
|
||||
|
||||
@ -10,7 +10,11 @@ export default defineConfig({
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@base-ui/react/form'],
|
||||
include: [
|
||||
'@base-ui/react/form',
|
||||
'@base-ui/react/merge-props',
|
||||
'@base-ui/react/use-render',
|
||||
],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
@ -5,6 +5,7 @@ import type { AnnotationItem, AnnotationItemBasic } from './type'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import type { App } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
@ -16,7 +17,6 @@ import ActionButton from '@/app/components/base/action-button'
|
||||
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -49,6 +49,7 @@ const Annotation: FC<Props> = (props) => {
|
||||
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
|
||||
const [list, setList] = useState<AnnotationItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [controlUpdateList, setControlUpdateList] = useState(() => Date.now())
|
||||
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
|
||||
@ -217,11 +218,22 @@ const Annotation: FC<Props> = (props) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -20,12 +20,12 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
@ -62,6 +62,7 @@ const ViewAnnotationModal: FC<Props> = ({
|
||||
const { formatTime } = useTimestamp()
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const totalPages = total ? Math.max(Math.ceil(total / APP_PAGE_LIMIT), 1) : 1
|
||||
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
|
||||
|
||||
// Update local state when item prop changes (e.g., when modal is reopened with updated data)
|
||||
@ -197,10 +198,15 @@ const ViewAnnotationModal: FC<Props> = ({
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
className="px-0"
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -67,9 +67,11 @@ vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div>loading-logs</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/pagination', () => ({
|
||||
default: ({ onChange }: { onChange: (page: number) => void }) => (
|
||||
<button onClick={() => onChange(1)}>go-to-page-2</button>
|
||||
vi.mock('@langgenius/dify-ui/pagination', () => ({
|
||||
Pagination: ({ onPageChange }: { onPageChange: (page: number) => void }) => (
|
||||
<div>
|
||||
<button onClick={() => onPageChange(2)}>go-to-page-2</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import { omit } from 'es-toolkit/object'
|
||||
@ -8,7 +9,6 @@ import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
|
||||
import { useChatConversations, useCompletionConversations } from '@/service/use-log'
|
||||
@ -98,6 +98,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
})
|
||||
|
||||
const total = isChatMode ? chatConversations?.total : completionConversations?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
const handleQueryParamsChange = useCallback((next: QueryParam) => {
|
||||
setCurrPage(0)
|
||||
@ -130,11 +131,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
onChange={handlePageChange}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => handlePageChange(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
@ -11,7 +12,6 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import EmptyElement from '@/app/components/app/log/empty-element'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useWorkflowLogs } from '@/service/use-log'
|
||||
@ -59,6 +59,7 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
params: query,
|
||||
})
|
||||
const total = workflowLogs?.total
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@ -76,11 +77,22 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? (
|
||||
<Pagination
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -1,155 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import usePagination from '../hook'
|
||||
|
||||
const defaultProps = {
|
||||
currentPage: 0,
|
||||
setCurrentPage: vi.fn(),
|
||||
totalPages: 10,
|
||||
edgePageCount: 2,
|
||||
middlePagesSiblingCount: 1,
|
||||
truncableText: '...',
|
||||
truncableClassName: 'truncable',
|
||||
}
|
||||
|
||||
describe('usePagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('pages', () => {
|
||||
it('should generate correct pages array', () => {
|
||||
const { result } = renderHook(() => usePagination(defaultProps))
|
||||
expect(result.current.pages).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
|
||||
it('should generate empty pages for totalPages 0', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 0 }))
|
||||
expect(result.current.pages).toEqual([])
|
||||
})
|
||||
|
||||
it('should generate single page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, totalPages: 1 }))
|
||||
expect(result.current.pages).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasPreviousPage / hasNextPage', () => {
|
||||
it('should have no previous page on first page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
|
||||
expect(result.current.hasPreviousPage).toBe(false)
|
||||
})
|
||||
|
||||
it('should have previous page when not on first page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 3 }))
|
||||
expect(result.current.hasPreviousPage).toBe(true)
|
||||
})
|
||||
|
||||
it('should have next page when not on last page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 1 }))
|
||||
expect(result.current.hasNextPage).toBe(true)
|
||||
})
|
||||
|
||||
it('should have no next page on last page', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 10 }))
|
||||
expect(result.current.hasNextPage).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('middlePages', () => {
|
||||
it('should return correct middle pages when at start', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
|
||||
// isReachedToFirst: currentPage(0) <= middlePagesSiblingCount(1), so slice(0, 3)
|
||||
expect(result.current.middlePages).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it('should return correct middle pages when in the middle', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// Not at start or end, slice(5-1, 5+1+1) = slice(4, 7) = [5, 6, 7]
|
||||
expect(result.current.middlePages).toEqual([5, 6, 7])
|
||||
})
|
||||
|
||||
it('should return correct middle pages when at end', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
|
||||
// isReachedToLast: currentPage(9) + middlePagesSiblingCount(1) >= totalPages(10), so slice(-3)
|
||||
expect(result.current.middlePages).toEqual([8, 9, 10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('previousPages and nextPages', () => {
|
||||
it('should return empty previousPages when at start', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 0 }))
|
||||
expect(result.current.previousPages).toEqual([])
|
||||
})
|
||||
|
||||
it('should return previousPages when in the middle', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// edgePageCount=2, so first 2 pages filtered by not in middlePages
|
||||
expect(result.current.previousPages).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('should return empty nextPages when at end', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 9 }))
|
||||
expect(result.current.nextPages).toEqual([])
|
||||
})
|
||||
|
||||
it('should return nextPages when in the middle', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// Last 2 pages: [9, 10], filtered by not in middlePages [5,6,7]
|
||||
expect(result.current.nextPages).toEqual([9, 10])
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncation', () => {
|
||||
it('should be previous truncable when middle pages are far from edge', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// previousPages=[1,2], middlePages=[5,6,7], 5 > 2+1 = true
|
||||
expect(result.current.isPreviousTruncable).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be previous truncable when pages are contiguous', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 2 }))
|
||||
expect(result.current.isPreviousTruncable).toBe(false)
|
||||
})
|
||||
|
||||
it('should be next truncable when middle pages are far from end edge', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
// middlePages=[5,6,7], nextPages=[9,10], 7+1 < 9 = true
|
||||
expect(result.current.isNextTruncable).toBe(true)
|
||||
})
|
||||
|
||||
it('should not be next truncable when pages are contiguous', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 7 }))
|
||||
expect(result.current.isNextTruncable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('passthrough values', () => {
|
||||
it('should pass through currentPage', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, currentPage: 5 }))
|
||||
expect(result.current.currentPage).toBe(5)
|
||||
})
|
||||
|
||||
it('should pass through setCurrentPage', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, setCurrentPage }))
|
||||
result.current.setCurrentPage(3)
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('should pass through truncableText', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableText: '…' }))
|
||||
expect(result.current.truncableText).toBe('…')
|
||||
})
|
||||
|
||||
it('should pass through truncableClassName', () => {
|
||||
const { result } = renderHook(() => usePagination({ ...defaultProps, truncableClassName: 'custom-trunc' }))
|
||||
expect(result.current.truncableClassName).toBe('custom-trunc')
|
||||
})
|
||||
|
||||
it('should use default truncableText', () => {
|
||||
const { currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount } = defaultProps
|
||||
const { result } = renderHook(() => usePagination({ currentPage, setCurrentPage, totalPages, edgePageCount, middlePagesSiblingCount }))
|
||||
expect(result.current.truncableText).toBe('...')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,444 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import CustomizedPagination from '../index'
|
||||
|
||||
describe('CustomizedPagination', () => {
|
||||
const defaultProps = {
|
||||
current: 0,
|
||||
onChange: vi.fn(),
|
||||
total: 100,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<CustomizedPagination {...defaultProps} />)
|
||||
expect(container)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display current page and total pages', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} />)
|
||||
// current + 1 = 1, totalPages = 10
|
||||
// The page info display shows "1 / 10" and page buttons also show numbers
|
||||
// current + 1 = 1, totalPages = 10
|
||||
// The page info display shows "1 / 10" and page buttons also show numbers
|
||||
expect(screen.getByText('/'))!.toBeInTheDocument()
|
||||
expect(screen.getAllByText('1').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should render prev and next buttons', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('should render page number buttons', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={50} limit={10} />)
|
||||
// 5 pages total, should see page numbers
|
||||
// 5 pages total, should see page numbers
|
||||
expect(screen.getByText('2'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('3'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display slash separator between current page and total', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
expect(screen.getByText('/'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<CustomizedPagination {...defaultProps} className="my-custom" />)
|
||||
const wrapper = container.firstChild as HTMLElement
|
||||
expect(wrapper)!.toHaveClass('my-custom')
|
||||
})
|
||||
|
||||
it('should default limit to 10', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={100} />)
|
||||
// totalPages = 100 / 10 = 10, displayed in the page info area
|
||||
expect(screen.getAllByText('10').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should calculate total pages based on custom limit', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={100} limit={25} />)
|
||||
// totalPages = 100 / 25 = 4, displayed in the page info area
|
||||
expect(screen.getAllByText('4').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('should disable prev button on first page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={0} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// First button is prev
|
||||
// First button is prev
|
||||
expect(buttons[0])!.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable next button on last page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={9} total={100} limit={10} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
// Last button is next
|
||||
// Last button is next
|
||||
expect(buttons[buttons.length - 1])!.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not render limit selector when onLimitChange is not provided', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
expect(screen.queryByText(/common\.pagination\.perPage/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render limit selector when onLimitChange is provided', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
// Should show limit options 10, 25, 50
|
||||
// Should show limit options 10, 25, 50
|
||||
expect(screen.getByText('25'))!.toBeInTheDocument()
|
||||
expect(screen.getByText('50'))!.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call onChange when next button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const nextButton = buttons[buttons.length - 1]
|
||||
fireEvent.click(nextButton!)
|
||||
expect(onChange).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should call onChange when prev button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0]!)
|
||||
expect(onChange).toHaveBeenCalledWith(4)
|
||||
})
|
||||
|
||||
it('should show input when page display is clicked', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
// Click the current page display (the div containing "1 / 10")
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
// Input should appear
|
||||
// Input should appear
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should navigate to entered page on Enter key', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '5' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(4) // 0-indexed
|
||||
})
|
||||
|
||||
it('should cancel input on Escape key', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={0} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
// Input should be hidden and page display should return
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('/'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should confirm input on blur-sm', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '3' } })
|
||||
fireEvent.blur(input)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should clamp page to max when input exceeds total pages', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} total={100} limit={10} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '999' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(9) // last page (0-indexed)
|
||||
})
|
||||
|
||||
it('should clamp page to min when input is less than 1', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={5} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(onChange).toHaveBeenCalledWith(0)
|
||||
})
|
||||
|
||||
it('should ignore non-numeric input and empty input', () => {
|
||||
render(<CustomizedPagination {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
expect(input)!.toHaveValue('')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
expect(input)!.toHaveValue('')
|
||||
})
|
||||
|
||||
it('should show per page tip on hover and hide on leave', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
|
||||
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
|
||||
|
||||
fireEvent.mouseEnter(container)
|
||||
// I18n mock returns ns.key
|
||||
// I18n mock returns ns.key
|
||||
expect(screen.getByText('common.pagination.perPage'))!.toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(container)
|
||||
expect(screen.queryByText('common.pagination.perPage')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onLimitChange when limit option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
fireEvent.click(screen.getByText('25'))
|
||||
expect(onLimitChange).toHaveBeenCalledWith(25)
|
||||
})
|
||||
|
||||
it('should call onLimitChange with 10 when 10 option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
|
||||
const container = screen.getByText('25').closest('.bg-components-segmented-control-bg-normal')!
|
||||
const option10 = Array.from(container.children).find(el => el.textContent === '10')!
|
||||
|
||||
fireEvent.click(option10)
|
||||
expect(onLimitChange).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('should call onLimitChange with 50 when 50 option is clicked', () => {
|
||||
const onLimitChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} onLimitChange={onLimitChange} />)
|
||||
fireEvent.click(screen.getByText('50'))
|
||||
expect(onLimitChange).toHaveBeenCalledWith(50)
|
||||
})
|
||||
|
||||
it('should call onChange when a page button is clicked', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={0} total={50} limit={10} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('3'))
|
||||
expect(onChange).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should correctly select active limit style for 25 and 50', () => {
|
||||
// Test limit 25
|
||||
const { container: containerA } = render(<CustomizedPagination current={0} total={100} limit={25} onChange={vi.fn()} onLimitChange={vi.fn()} />)
|
||||
const wrapper25 = Array.from(containerA.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '25')!
|
||||
expect(wrapper25)!.toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
|
||||
// Test limit 50
|
||||
const { container: containerB } = render(<CustomizedPagination current={0} total={100} limit={50} onChange={vi.fn()} onLimitChange={vi.fn()} />)
|
||||
const wrapper50 = Array.from(containerB.querySelectorAll('div.system-sm-medium')).find(el => el.textContent === '50')!
|
||||
expect(wrapper50)!.toHaveClass('bg-components-segmented-control-item-active-bg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle total of 0', () => {
|
||||
const { container } = render(<CustomizedPagination {...defaultProps} total={0} />)
|
||||
expect(container)!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle confirm when input value is unchanged (covers false branch of empty string check)', () => {
|
||||
vi.useFakeTimers()
|
||||
const onChange = vi.fn()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} onChange={onChange} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
// Blur without changing anything
|
||||
fireEvent.blur(input)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
// onChange should NOT be called
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore other keys in handleInputKeyDown (covers false branch of Escape check)', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trigger handleInputConfirm with empty string specifically on keydown Enter', async () => {
|
||||
const { userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.clear(input)
|
||||
await user.type(input, '{Enter}')
|
||||
|
||||
// Wait for debounce 500ms
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
// Validates `inputValue === ''` path under `handleInputConfirm` triggered by Enter
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should explicitly trigger Escape key logic in handleInputKeyDown', async () => {
|
||||
const { userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
await user.type(input, '{Escape}')
|
||||
|
||||
// Wait for debounce 500ms
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle single page', () => {
|
||||
render(<CustomizedPagination {...defaultProps} total={5} limit={10} />)
|
||||
// totalPages = 1, both buttons should be disabled
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons[0])!.toBeDisabled()
|
||||
expect(buttons[buttons.length - 1])!.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should restore input value when blurred with empty value', () => {
|
||||
render(<CustomizedPagination {...defaultProps} current={4} />)
|
||||
fireEvent.click(screen.getByText('/'))
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.blur(input)
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
// Should close input without calling onChange, restoring to current + 1
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,549 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Pagination } from '../pagination'
|
||||
|
||||
// Helper to render Pagination with common defaults
|
||||
function renderPagination({
|
||||
currentPage = 0,
|
||||
totalPages = 10,
|
||||
setCurrentPage = vi.fn(),
|
||||
edgePageCount = 2,
|
||||
middlePagesSiblingCount = 1,
|
||||
truncableText = '...',
|
||||
truncableClassName = 'truncable',
|
||||
children,
|
||||
}: {
|
||||
currentPage?: number
|
||||
totalPages?: number
|
||||
setCurrentPage?: (page: number) => void
|
||||
edgePageCount?: number
|
||||
middlePagesSiblingCount?: number
|
||||
truncableText?: string
|
||||
truncableClassName?: string
|
||||
children?: React.ReactNode
|
||||
} = {}) {
|
||||
return render(
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
setCurrentPage={setCurrentPage}
|
||||
edgePageCount={edgePageCount}
|
||||
middlePagesSiblingCount={middlePagesSiblingCount}
|
||||
truncableText={truncableText}
|
||||
truncableClassName={truncableClassName}
|
||||
>
|
||||
{children}
|
||||
</Pagination>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Pagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
const { container } = renderPagination()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render children', () => {
|
||||
renderPagination({ children: <span>child content</span> })
|
||||
expect(screen.getByText(/child content/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply className to wrapper div', () => {
|
||||
const { container } = render(
|
||||
<Pagination
|
||||
currentPage={0}
|
||||
totalPages={5}
|
||||
setCurrentPage={vi.fn()}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
className="my-pagination"
|
||||
>
|
||||
<span>test</span>
|
||||
</Pagination>,
|
||||
)
|
||||
expect(container.firstChild).toHaveClass('my-pagination')
|
||||
})
|
||||
|
||||
it('should apply data-testid when provided', () => {
|
||||
render(
|
||||
<Pagination
|
||||
currentPage={0}
|
||||
totalPages={5}
|
||||
setCurrentPage={vi.fn()}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
dataTestId="my-pagination"
|
||||
>
|
||||
<span>test</span>
|
||||
</Pagination>,
|
||||
)
|
||||
expect(screen.getByTestId('my-pagination')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PrevButton', () => {
|
||||
it('should render prev button', () => {
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByText(/prev/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentPage with previous page when clicked', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/prev/i))
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should not navigate below page 0', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/prev/i))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled on first page', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should navigate on Enter key press', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('should not navigate on Enter when disabled', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/prev/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render with custom as element', () => {
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
children: <Pagination.PrevButton as={<div />}>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByText(/prev/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply dataTestId', () => {
|
||||
renderPagination({
|
||||
currentPage: 3,
|
||||
children: <Pagination.PrevButton dataTestId="prev-btn">Prev</Pagination.PrevButton>,
|
||||
})
|
||||
expect(screen.getByTestId('prev-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NextButton', () => {
|
||||
it('should render next button', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
expect(screen.getByText(/next/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call setCurrentPage with next page when clicked', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/next/i))
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should not navigate beyond last page', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 9,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText(/next/i))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should be disabled on last page', () => {
|
||||
renderPagination({
|
||||
currentPage: 9,
|
||||
totalPages: 10,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should navigate on Enter key press', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('should not navigate on Enter when disabled', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 9,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText(/next/i).closest('button')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply dataTestId', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
children: <Pagination.NextButton dataTestId="next-btn">Next</Pagination.NextButton>,
|
||||
})
|
||||
expect(screen.getByTestId('next-btn')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PageButton', () => {
|
||||
it('should render page number buttons', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply activeClassName to current page', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// current page is 2, so page 3 (1-indexed) should be active
|
||||
expect(screen.getByText('3').closest('a')).toHaveClass('active')
|
||||
})
|
||||
|
||||
it('should apply inactiveClassName to non-current pages', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1').closest('a')).toHaveClass('inactive')
|
||||
})
|
||||
|
||||
it('should call setCurrentPage when a page button is clicked', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 5,
|
||||
setCurrentPage,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
fireEvent.click(screen.getByText('3'))
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(2) // 0-indexed
|
||||
})
|
||||
|
||||
it('should navigate on Enter key press on a page button', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 5,
|
||||
setCurrentPage,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
fireEvent.keyDown(screen.getByText('4').closest('a')!, { key: 'Enter', code: 'Enter', keyCode: 13, which: 13 })
|
||||
expect(setCurrentPage).toHaveBeenCalledWith(3) // 0-indexed
|
||||
})
|
||||
|
||||
it('should render truncable text when pages are truncated', () => {
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 20,
|
||||
edgePageCount: 2,
|
||||
middlePagesSiblingCount: 1,
|
||||
truncableText: '...',
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// With 20 pages and current at 5, there should be truncation
|
||||
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle single page', () => {
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 1,
|
||||
setCurrentPage,
|
||||
children: (
|
||||
<>
|
||||
<Pagination.PrevButton>Prev</Pagination.PrevButton>
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
<Pagination.NextButton>Next</Pagination.NextButton>
|
||||
</>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText(/prev/i).closest('button')).toBeDisabled()
|
||||
expect(screen.getByText(/next/i).closest('button')).toBeDisabled()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle zero total pages', () => {
|
||||
const { container } = renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 0,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should cover undefined active/inactive dataTestIds', () => {
|
||||
// Re-render PageButton without active/inactive data test ids to hit the undefined branch in cn() fallback
|
||||
renderPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
renderExtraProps={page => ({ 'aria-label': `Page ${page}` })}
|
||||
/>
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('2')).toHaveAttribute('aria-label', 'Page 2')
|
||||
})
|
||||
|
||||
it('should cover nextPages when edge pages fall perfectly into middle Pages', () => {
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 10,
|
||||
edgePageCount: 8, // Very large edge page count to hit the filter(!middlePages.includes) branches
|
||||
middlePagesSiblingCount: 1,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
expect(screen.getByText('10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide truncation element if truncable is false', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 5,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 1,
|
||||
// When we are at page 2, middle pages are [2, 3, 4] (if 0-indexed, wait, currentPage is 0-indexed in hook?)
|
||||
// Let's just render the component which calls the internal TruncableElement, when previous/next are NOT truncable
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Truncation only happens if middlePages > previousPages.last + 1
|
||||
expect(screen.queryByText('...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hit getAllPreviousPages with less than 1 element', () => {
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
totalPages: 10,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 0,
|
||||
children: <Pagination.PageButton className="btn" activeClassName="act" inactiveClassName="inact" />,
|
||||
})
|
||||
// With currentPage = 0, middlePages = [1], getAllPreviousPages() -> slice(0, 0) -> []
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fire previous() keyboard event even if it does nothing without crashing', () => {
|
||||
// Line 38: pagination.currentPage + 1 > 1 check is usually guarded by disabled, but we can verify it explicitly.
|
||||
const setCurrentPage = vi.fn()
|
||||
// Use a span so that 'disabled' attribute doesn't prevent fireEvent.click from firing
|
||||
renderPagination({
|
||||
currentPage: 0,
|
||||
setCurrentPage,
|
||||
children: <Pagination.PrevButton as={<span />}>Prev</Pagination.PrevButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Prev'))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire next() even if it does nothing without crashing', () => {
|
||||
// Line 73: pagination.currentPage + 1 < pages.length verify
|
||||
const setCurrentPage = vi.fn()
|
||||
renderPagination({
|
||||
currentPage: 10,
|
||||
totalPages: 10,
|
||||
setCurrentPage,
|
||||
children: <Pagination.NextButton as={<span />}>Next</Pagination.NextButton>,
|
||||
})
|
||||
fireEvent.click(screen.getByText('Next'))
|
||||
expect(setCurrentPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to undefined when truncableClassName is empty', () => {
|
||||
// Line 115: `<li className={truncableClassName || undefined}>{truncableText}</li>`
|
||||
renderPagination({
|
||||
currentPage: 5,
|
||||
totalPages: 10,
|
||||
truncableClassName: '',
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Should not have a class attribute
|
||||
const truncableElements = screen.getAllByText('...')
|
||||
expect(truncableElements[0]).not.toHaveAttribute('class')
|
||||
})
|
||||
|
||||
it('should handle dataTestIdActive and dataTestIdInactive completely', () => {
|
||||
// Lines 137-144
|
||||
renderPagination({
|
||||
currentPage: 1, // 0-indexed, so page 2 is active
|
||||
totalPages: 5,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdActive="active-test-id"
|
||||
dataTestIdInactive="inactive-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
|
||||
const activeBtn = screen.getByTestId('active-test-id')
|
||||
expect(activeBtn).toHaveTextContent('2')
|
||||
|
||||
const inactiveBtn = screen.getByTestId('inactive-test-id-1') // page 1
|
||||
expect(inactiveBtn).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should hit getAllNextPages.length < 1 in hook', () => {
|
||||
renderPagination({
|
||||
currentPage: 2,
|
||||
totalPages: 3,
|
||||
edgePageCount: 1,
|
||||
middlePagesSiblingCount: 0,
|
||||
children: (
|
||||
<Pagination.PageButton className="page-btn" activeClassName="active" inactiveClassName="inactive" />
|
||||
),
|
||||
})
|
||||
// Current is 3 (index 2). middlePages = [3]. getAllNextPages = slice(3, 3) = []
|
||||
// This will trigger the `getAllNextPages.length < 1` branch
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only dataTestIdInactive without dataTestIdActive', () => {
|
||||
renderPagination({
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdInactive="inactive-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// Missing dataTestIdActive branch coverage on line 144
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle only dataTestIdActive without dataTestIdInactive', () => {
|
||||
renderPagination({
|
||||
currentPage: 1, // page 2 is active
|
||||
totalPages: 3,
|
||||
children: (
|
||||
<Pagination.PageButton
|
||||
className="page-btn"
|
||||
activeClassName="active"
|
||||
inactiveClassName="inactive"
|
||||
dataTestIdActive="active-test-id"
|
||||
/>
|
||||
),
|
||||
})
|
||||
// This hits the branch where dataTestIdActive exists but not dataTestIdInactive
|
||||
expect(screen.getByTestId('active-test-id')).toHaveTextContent('2')
|
||||
expect(screen.queryByTestId('inactive-test-id-1')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,94 +0,0 @@
|
||||
import type { IPaginationProps, IUsePagination } from './type'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
const usePagination = ({
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
truncableText = '...',
|
||||
truncableClassName = '',
|
||||
totalPages,
|
||||
edgePageCount,
|
||||
middlePagesSiblingCount,
|
||||
}: IPaginationProps): IUsePagination => {
|
||||
const pages = React.useMemo(() => Array.from({ length: totalPages }, (_, i) => i + 1), [totalPages])
|
||||
|
||||
const hasPreviousPage = currentPage > 1
|
||||
const hasNextPage = currentPage < totalPages
|
||||
|
||||
const isReachedToFirst = currentPage <= middlePagesSiblingCount
|
||||
const isReachedToLast = currentPage + middlePagesSiblingCount >= totalPages
|
||||
|
||||
const middlePages = React.useMemo(() => {
|
||||
const middlePageCount = middlePagesSiblingCount * 2 + 1
|
||||
if (isReachedToFirst)
|
||||
return pages.slice(0, middlePageCount)
|
||||
|
||||
if (isReachedToLast)
|
||||
return pages.slice(-middlePageCount)
|
||||
|
||||
return pages.slice(
|
||||
currentPage - middlePagesSiblingCount,
|
||||
currentPage + middlePagesSiblingCount + 1,
|
||||
)
|
||||
}, [currentPage, isReachedToFirst, isReachedToLast, middlePagesSiblingCount, pages])
|
||||
|
||||
const getAllPreviousPages = useCallback(() => {
|
||||
return pages.slice(0, middlePages[0]! - 1)
|
||||
}, [middlePages, pages])
|
||||
|
||||
const previousPages = React.useMemo(() => {
|
||||
if (isReachedToFirst || getAllPreviousPages().length < 1)
|
||||
return []
|
||||
|
||||
return pages
|
||||
.slice(0, edgePageCount)
|
||||
.filter(p => !middlePages.includes(p))
|
||||
}, [edgePageCount, getAllPreviousPages, isReachedToFirst, middlePages, pages])
|
||||
|
||||
const getAllNextPages = React.useMemo(() => {
|
||||
return pages.slice(
|
||||
middlePages[middlePages.length - 1],
|
||||
pages[pages.length],
|
||||
)
|
||||
}, [pages, middlePages])
|
||||
|
||||
const nextPages = React.useMemo(() => {
|
||||
if (isReachedToLast)
|
||||
return []
|
||||
|
||||
if (getAllNextPages.length < 1)
|
||||
return []
|
||||
|
||||
return pages
|
||||
.slice(pages.length - edgePageCount, pages.length)
|
||||
.filter(p => !middlePages.includes(p))
|
||||
}, [edgePageCount, getAllNextPages.length, isReachedToLast, middlePages, pages])
|
||||
|
||||
const isPreviousTruncable = React.useMemo(() => {
|
||||
// Is truncable if first value of middlePage is larger than last value of previousPages
|
||||
return middlePages[0]! > previousPages[previousPages.length - 1]! + 1
|
||||
}, [previousPages, middlePages])
|
||||
|
||||
const isNextTruncable = React.useMemo(() => {
|
||||
// Is truncable if last value of middlePage is larger than first value of previousPages
|
||||
return middlePages[middlePages.length - 1]! + 1 < nextPages[0]!
|
||||
}, [nextPages, middlePages])
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
truncableText,
|
||||
truncableClassName,
|
||||
pages,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
previousPages,
|
||||
isPreviousTruncable,
|
||||
middlePages,
|
||||
isNextTruncable,
|
||||
nextPages,
|
||||
}
|
||||
}
|
||||
|
||||
export default usePagination
|
||||
@ -1,81 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useMemo, useState } from 'react'
|
||||
import Pagination from '.'
|
||||
|
||||
const TOTAL_ITEMS = 120
|
||||
|
||||
const PaginationDemo = ({
|
||||
initialPage = 0,
|
||||
initialLimit = 10,
|
||||
}: {
|
||||
initialPage?: number
|
||||
initialLimit?: number
|
||||
}) => {
|
||||
const [current, setCurrent] = useState(initialPage)
|
||||
const [limit, setLimit] = useState(initialLimit)
|
||||
|
||||
const pageSummary = useMemo(() => {
|
||||
const start = current * limit + 1
|
||||
const end = Math.min((current + 1) * limit, TOTAL_ITEMS)
|
||||
return `${start}-${end} of ${TOTAL_ITEMS}`
|
||||
}, [current, limit])
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
|
||||
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
|
||||
<span>Log pagination</span>
|
||||
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 font-medium text-text-secondary">
|
||||
{pageSummary}
|
||||
</span>
|
||||
</div>
|
||||
<Pagination
|
||||
current={current}
|
||||
total={TOTAL_ITEMS}
|
||||
limit={limit}
|
||||
onChange={setCurrent}
|
||||
onLimitChange={(nextLimit) => {
|
||||
setCurrent(0)
|
||||
setLimit(nextLimit)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Navigation/Pagination',
|
||||
component: PaginationDemo,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Paginate long lists with optional per-page selector. Demonstrates the inline page jump input and quick limit toggles.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
initialPage: 0,
|
||||
initialLimit: 10,
|
||||
},
|
||||
argTypes: {
|
||||
initialPage: {
|
||||
control: { type: 'number', min: 0, max: 9, step: 1 },
|
||||
},
|
||||
initialLimit: {
|
||||
control: { type: 'radio' },
|
||||
options: [10, 25, 50],
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof PaginationDemo>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {}
|
||||
|
||||
export const StartAtMiddle: Story = {
|
||||
args: {
|
||||
initialPage: 4,
|
||||
},
|
||||
}
|
||||
@ -1,201 +0,0 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Pagination } from './pagination'
|
||||
|
||||
export type Props = {
|
||||
className?: string
|
||||
current: number
|
||||
onChange: (cur: number) => void
|
||||
total: number
|
||||
limit?: number
|
||||
onLimitChange?: (limit: number) => void
|
||||
}
|
||||
|
||||
const CustomizedPagination: FC<Props> = ({
|
||||
className,
|
||||
current,
|
||||
onChange,
|
||||
total,
|
||||
limit = 10,
|
||||
onLimitChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const inputRef = React.useRef<HTMLDivElement>(null)
|
||||
const [showInput, setShowInput] = React.useState(false)
|
||||
const [inputValue, setInputValue] = React.useState<string | number>(current + 1)
|
||||
const [showPerPageTip, setShowPerPageTip] = React.useState(false)
|
||||
|
||||
const { run: handlePaging } = useDebounceFn((value: string) => {
|
||||
if (Number.parseInt(value) > totalPages) {
|
||||
setInputValue(totalPages)
|
||||
onChange(totalPages - 1)
|
||||
setShowInput(false)
|
||||
return
|
||||
}
|
||||
if (Number.parseInt(value) < 1) {
|
||||
setInputValue(1)
|
||||
onChange(0)
|
||||
setShowInput(false)
|
||||
return
|
||||
}
|
||||
onChange(Number.parseInt(value) - 1)
|
||||
setInputValue(Number.parseInt(value))
|
||||
setShowInput(false)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value
|
||||
if (!value)
|
||||
return setInputValue('')
|
||||
if (isNaN(Number.parseInt(value)))
|
||||
return setInputValue('')
|
||||
setInputValue(Number.parseInt(value))
|
||||
}
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue !== '' && String(inputValue) !== String(current + 1)) {
|
||||
handlePaging(String(inputValue))
|
||||
return
|
||||
}
|
||||
|
||||
if (inputValue === '')
|
||||
setInputValue(current + 1)
|
||||
|
||||
setShowInput(false)
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleInputConfirm()
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setInputValue(current + 1)
|
||||
setShowInput(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
handleInputConfirm()
|
||||
}
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
className={cn('flex w-full items-center px-6 py-3 select-none', className)}
|
||||
currentPage={current}
|
||||
edgePageCount={2}
|
||||
middlePagesSiblingCount={1}
|
||||
setCurrentPage={onChange}
|
||||
totalPages={totalPages}
|
||||
truncableClassName="flex items-center justify-center w-8 px-1 py-2 system-sm-medium text-text-tertiary"
|
||||
truncableText="..."
|
||||
>
|
||||
<div className="flex items-center gap-0.5 rounded-[10px] bg-background-section-burn p-0.5">
|
||||
<Pagination.PrevButton
|
||||
as={<div></div>}
|
||||
disabled={current === 0}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-7 px-1.5"
|
||||
disabled={current === 0}
|
||||
>
|
||||
<RiArrowLeftLine className="size-4" />
|
||||
</Button>
|
||||
</Pagination.PrevButton>
|
||||
{!showInput && (
|
||||
<div
|
||||
ref={inputRef}
|
||||
className="flex items-center gap-0.5 rounded-lg px-2 py-1.5 hover:cursor-text hover:bg-state-base-hover-alt"
|
||||
onClick={() => setShowInput(true)}
|
||||
>
|
||||
<div className="system-xs-medium text-text-secondary">{current + 1}</div>
|
||||
<div className="system-xs-medium text-text-quaternary">/</div>
|
||||
<div className="system-xs-medium text-text-secondary">{totalPages}</div>
|
||||
</div>
|
||||
)}
|
||||
{showInput && (
|
||||
<Input
|
||||
styleCss={{
|
||||
height: '28px',
|
||||
width: `${inputRef.current?.clientWidth}px`,
|
||||
}}
|
||||
placeholder=""
|
||||
autoFocus
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
)}
|
||||
<Pagination.NextButton
|
||||
as={<div></div>}
|
||||
disabled={current === totalPages - 1}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="size-7 px-1.5"
|
||||
disabled={current === totalPages - 1}
|
||||
>
|
||||
<RiArrowRightLine className="size-4" />
|
||||
</Button>
|
||||
</Pagination.NextButton>
|
||||
</div>
|
||||
<div className={cn('flex grow list-none items-center justify-center gap-1')}>
|
||||
<Pagination.PageButton
|
||||
className="flex min-w-8 cursor-pointer items-center justify-center rounded-lg px-1 py-2 system-sm-medium hover:bg-components-button-ghost-bg-hover"
|
||||
activeClassName="bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-ghost-bg-hover"
|
||||
inactiveClassName="text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
{onLimitChange && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="w-[51px] shrink-0 text-end system-2xs-regular-uppercase text-text-tertiary">{showPerPageTip ? t('pagination.perPage', { ns: 'common' }) : ''}</div>
|
||||
<div
|
||||
className="flex items-center gap-px rounded-[10px] bg-components-segmented-control-bg-normal p-0.5"
|
||||
onMouseEnter={() => setShowPerPageTip(true)}
|
||||
onMouseLeave={() => setShowPerPageTip(false)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
limit === 10 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
|
||||
)}
|
||||
onClick={() => onLimitChange?.(10)}
|
||||
>
|
||||
10
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
limit === 25 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
|
||||
)}
|
||||
onClick={() => onLimitChange?.(25)}
|
||||
>
|
||||
25
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'cursor-pointer rounded-lg border-[0.5px] border-transparent px-2.5 py-1.5 system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
limit === 50 && 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg text-text-secondary shadow-xs hover:bg-components-segmented-control-item-active-bg',
|
||||
)}
|
||||
onClick={() => onLimitChange?.(50)}
|
||||
>
|
||||
50
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Pagination>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomizedPagination
|
||||
@ -1,190 +0,0 @@
|
||||
import type {
|
||||
ButtonProps,
|
||||
IPagination,
|
||||
IPaginationProps,
|
||||
PageButtonProps,
|
||||
} from './type'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import usePagination from './hook'
|
||||
|
||||
const defaultState: IPagination = {
|
||||
currentPage: 0,
|
||||
setCurrentPage: noop,
|
||||
truncableText: '...',
|
||||
truncableClassName: '',
|
||||
pages: [],
|
||||
hasPreviousPage: false,
|
||||
hasNextPage: false,
|
||||
previousPages: [],
|
||||
isPreviousTruncable: false,
|
||||
middlePages: [],
|
||||
isNextTruncable: false,
|
||||
nextPages: [],
|
||||
}
|
||||
|
||||
const PaginationContext: React.Context<IPagination> = React.createContext<IPagination>(defaultState)
|
||||
|
||||
const PrevButton = ({
|
||||
className,
|
||||
children,
|
||||
dataTestId,
|
||||
as = <button type="button" />,
|
||||
...buttonProps
|
||||
}: ButtonProps) => {
|
||||
const pagination = React.useContext(PaginationContext)
|
||||
const previous = () => {
|
||||
if (pagination.currentPage + 1 > 1)
|
||||
pagination.setCurrentPage(pagination.currentPage - 1)
|
||||
}
|
||||
|
||||
const disabled = pagination.currentPage === 0
|
||||
|
||||
return (
|
||||
<as.type
|
||||
{...buttonProps}
|
||||
{...as.props}
|
||||
className={cn(className, as.props.className)}
|
||||
onClick={() => previous()}
|
||||
tabIndex={disabled ? '-1' : 0}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.key === 'Enter' && !disabled)
|
||||
previous()
|
||||
}}
|
||||
>
|
||||
{as.props.children ?? children}
|
||||
</as.type>
|
||||
)
|
||||
}
|
||||
|
||||
const NextButton = ({
|
||||
className,
|
||||
children,
|
||||
dataTestId,
|
||||
as = <button type="button" />,
|
||||
...buttonProps
|
||||
}: ButtonProps) => {
|
||||
const pagination = React.useContext(PaginationContext)
|
||||
const next = () => {
|
||||
if (pagination.currentPage + 1 < pagination.pages.length)
|
||||
pagination.setCurrentPage(pagination.currentPage + 1)
|
||||
}
|
||||
|
||||
const disabled = pagination.currentPage === pagination.pages.length - 1
|
||||
|
||||
return (
|
||||
<as.type
|
||||
{...buttonProps}
|
||||
{...as.props}
|
||||
className={cn(className, as.props.className)}
|
||||
onClick={() => next()}
|
||||
tabIndex={disabled ? '-1' : 0}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
event.preventDefault()
|
||||
if (event.key === 'Enter' && !disabled)
|
||||
next()
|
||||
}}
|
||||
>
|
||||
{as.props.children ?? children}
|
||||
</as.type>
|
||||
)
|
||||
}
|
||||
|
||||
type ITruncableElementProps = {
|
||||
prev?: boolean
|
||||
}
|
||||
|
||||
const TruncableElement = ({ prev }: ITruncableElementProps) => {
|
||||
const pagination: IPagination = React.useContext(PaginationContext)
|
||||
|
||||
const {
|
||||
isPreviousTruncable,
|
||||
isNextTruncable,
|
||||
truncableText,
|
||||
truncableClassName,
|
||||
} = pagination
|
||||
|
||||
return ((isPreviousTruncable && prev === true) || (isNextTruncable && !prev))
|
||||
? (
|
||||
<li className={truncableClassName || undefined}>{truncableText}</li>
|
||||
)
|
||||
: null
|
||||
}
|
||||
|
||||
const PageButton = ({
|
||||
as = <a />,
|
||||
className,
|
||||
dataTestIdActive,
|
||||
dataTestIdInactive,
|
||||
activeClassName,
|
||||
inactiveClassName,
|
||||
renderExtraProps,
|
||||
}: PageButtonProps) => {
|
||||
const pagination: IPagination = React.useContext(PaginationContext)
|
||||
|
||||
const renderPageButton = (page: number) => (
|
||||
<li key={page}>
|
||||
<as.type
|
||||
data-testid={
|
||||
cn({
|
||||
[`${dataTestIdActive}`]:
|
||||
dataTestIdActive && pagination.currentPage + 1 === page,
|
||||
[`${dataTestIdInactive}-${page}`]:
|
||||
dataTestIdActive && pagination.currentPage + 1 !== page,
|
||||
}) || undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
onKeyDown={(event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter')
|
||||
pagination.setCurrentPage(page - 1)
|
||||
}}
|
||||
onClick={() => pagination.setCurrentPage(page - 1)}
|
||||
className={cn(
|
||||
className,
|
||||
pagination.currentPage + 1 === page
|
||||
? activeClassName
|
||||
: inactiveClassName,
|
||||
)}
|
||||
{...as.props}
|
||||
{...(renderExtraProps ? renderExtraProps(page) : {})}
|
||||
>
|
||||
{page}
|
||||
</as.type>
|
||||
</li>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{pagination.previousPages.map(renderPageButton)}
|
||||
<TruncableElement prev />
|
||||
{pagination.middlePages.map(renderPageButton)}
|
||||
<TruncableElement />
|
||||
{pagination.nextPages.map(renderPageButton)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Pagination = ({
|
||||
dataTestId,
|
||||
...paginationProps
|
||||
}: IPaginationProps & { dataTestId?: string }) => {
|
||||
const pagination = usePagination(paginationProps)
|
||||
|
||||
return (
|
||||
<PaginationContext.Provider value={pagination}>
|
||||
<div className={paginationProps.className} data-testid={dataTestId}>
|
||||
{paginationProps.children}
|
||||
</div>
|
||||
</PaginationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
Pagination.PrevButton = PrevButton
|
||||
Pagination.NextButton = NextButton
|
||||
Pagination.PageButton = PageButton
|
||||
@ -1,64 +0,0 @@
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
type ElementProps = {
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type IBasePaginationProps = {
|
||||
currentPage: number
|
||||
setCurrentPage: (page: number) => void
|
||||
truncableText?: string
|
||||
truncableClassName?: string
|
||||
}
|
||||
|
||||
type IPaginationProps = IBasePaginationProps & {
|
||||
totalPages: number
|
||||
edgePageCount: number
|
||||
middlePagesSiblingCount: number
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
type IUsePagination = IBasePaginationProps & {
|
||||
pages: number[]
|
||||
hasPreviousPage: boolean
|
||||
hasNextPage: boolean
|
||||
previousPages: number[]
|
||||
isPreviousTruncable: boolean
|
||||
middlePages: number[]
|
||||
isNextTruncable: boolean
|
||||
nextPages: number[]
|
||||
}
|
||||
|
||||
type IPagination = IUsePagination & {
|
||||
setCurrentPage: (page: number) => void
|
||||
}
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
as?: React.ReactElement<ElementProps>
|
||||
children?: string | React.ReactNode
|
||||
className?: string
|
||||
dataTestId?: string
|
||||
}
|
||||
|
||||
type PageButtonProps = ButtonProps & {
|
||||
/**
|
||||
* Provide a custom ReactElement (e.g. Next/Link)
|
||||
*/
|
||||
as?: React.ReactElement<ElementProps>
|
||||
activeClassName?: string
|
||||
inactiveClassName?: string
|
||||
dataTestIdActive?: string
|
||||
dataTestIdInactive?: string
|
||||
renderExtraProps?: (pageNum: number) => {}
|
||||
}
|
||||
|
||||
export type {
|
||||
ButtonProps,
|
||||
IPagination,
|
||||
IPaginationProps,
|
||||
IUsePagination,
|
||||
PageButtonProps,
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
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 { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
@ -9,6 +8,14 @@ import DocumentList from '../../list'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
type PaginationProps = {
|
||||
current: number
|
||||
onChange: (page: number) => void
|
||||
total: number
|
||||
limit?: number
|
||||
onLimitChange?: (limit: number) => void
|
||||
}
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
import type { Props as PaginationProps } from '@/app/components/base/pagination'
|
||||
import type { SimpleDocumentDetail } from '@/models/datasets'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal'
|
||||
import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata'
|
||||
import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail'
|
||||
@ -19,6 +18,15 @@ import RenameModal from './rename-modal'
|
||||
|
||||
type LocalDoc = SimpleDocumentDetail & { percent?: number }
|
||||
|
||||
type PaginationProps = {
|
||||
className?: string
|
||||
current: number
|
||||
total: number
|
||||
limit?: number
|
||||
onChange: (page: number) => void
|
||||
onLimitChange?: (limit: number) => void
|
||||
}
|
||||
|
||||
type DocumentListProps = {
|
||||
embeddingAvailable: boolean
|
||||
documents: LocalDoc[]
|
||||
@ -48,6 +56,8 @@ const DocumentList = ({
|
||||
onSortChange,
|
||||
}: DocumentListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const pageSize = pagination.limit ?? 10
|
||||
const totalPages = Math.max(Math.ceil(pagination.total / pageSize), 1)
|
||||
const datasetConfig = useDatasetDetailContext(s => s.dataset)
|
||||
const chunkingMode = datasetConfig?.doc_form
|
||||
const isGeneralMode = chunkingMode !== ChunkingMode.parentChild
|
||||
@ -198,8 +208,25 @@ const DocumentList = ({
|
||||
|
||||
{!!pagination.total && (
|
||||
<Pagination
|
||||
{...pagination}
|
||||
className="w-full shrink-0"
|
||||
className="shrink-0"
|
||||
page={pagination.current + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => pagination.onChange(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={pagination.onLimitChange
|
||||
? {
|
||||
value: pageSize,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: pagination.onLimitChange,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -203,18 +203,22 @@ vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <hr data-testid="divider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/pagination', () => ({
|
||||
default: ({ current, total, onChange, onLimitChange }: {
|
||||
current: number
|
||||
total: number
|
||||
onChange: (page: number) => void
|
||||
onLimitChange: (limit: number) => void
|
||||
vi.mock('@langgenius/dify-ui/pagination', () => ({
|
||||
Pagination: ({ page, totalPages, onPageChange, pageSize }: {
|
||||
page: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
pageSize?: {
|
||||
onValueChange: (limit: number) => void
|
||||
}
|
||||
}) => (
|
||||
<div data-testid="pagination">
|
||||
<span data-testid="current-page">{current}</span>
|
||||
<span data-testid="total-items">{total}</span>
|
||||
<button data-testid="next-page" onClick={() => onChange(current + 1)}>Next</button>
|
||||
<button data-testid="change-limit" onClick={() => onLimitChange(20)}>Change Limit</button>
|
||||
<span data-testid="current-page">{page - 1}</span>
|
||||
<span data-testid="total-pages">{totalPages}</span>
|
||||
<button data-testid="next-page" onClick={() => onPageChange(page + 1)}>Next</button>
|
||||
{pageSize && (
|
||||
<button data-testid="change-limit" onClick={() => pageSize.onValueChange(20)}>Change Limit</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -1180,15 +1184,14 @@ describe('Inline callback and hook initialization coverage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Covers paginationTotal in full-doc mode
|
||||
it('should compute pagination total from child chunk data in full-doc mode', () => {
|
||||
it('should compute pagination pages from child chunk data in full-doc mode', () => {
|
||||
mockDocForm.current = ChunkingModeEnum.parentChild
|
||||
mockParentMode.current = 'full-doc'
|
||||
mockChildSegmentListData.total = 42
|
||||
|
||||
render(<Completed {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
expect(screen.getByTestId('total-items'))!.toHaveTextContent('42')
|
||||
expect(screen.getByTestId('total-pages'))!.toHaveTextContent('5')
|
||||
})
|
||||
|
||||
// Covers search input change
|
||||
|
||||
@ -3,10 +3,10 @@ import type { FC } from 'react'
|
||||
import type { SegmentListContextValue } from './segment-list-context'
|
||||
import type { SegmentImportStatus } from '@/types/dataset'
|
||||
import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import {
|
||||
useChunkListAllKey,
|
||||
useChunkListDisabledKey,
|
||||
@ -142,10 +142,11 @@ const Completed: FC<ICompletedProps> = ({
|
||||
return childSegmentDataHook.childChunkListData?.total || 0
|
||||
return segmentListDataHook.segmentListData?.total || 0
|
||||
}, [segmentListDataHook.isFullDocMode, childSegmentDataHook.childChunkListData, segmentListDataHook.segmentListData])
|
||||
const totalPages = Math.max(Math.ceil(paginationTotal / limit), 1)
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = useCallback((page: number) => {
|
||||
setCurrentPage(page + 1)
|
||||
setCurrentPage(page)
|
||||
}, [])
|
||||
|
||||
// Context value
|
||||
@ -225,12 +226,22 @@ const Completed: FC<ICompletedProps> = ({
|
||||
{/* Pagination */}
|
||||
<Divider type="horizontal" className="mx-6 my-0 h-px w-auto bg-divider-subtle" />
|
||||
<Pagination
|
||||
current={currentPage - 1}
|
||||
onChange={handlePageChange}
|
||||
total={paginationTotal}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
className={segmentListDataHook.isFullDocMode ? 'px-3' : ''}
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
pageSize={{
|
||||
value: limit,
|
||||
options: [10, 25, 50],
|
||||
onValueChange: setLimit,
|
||||
label: t('pagination.perPage', { ns: 'common' }),
|
||||
ariaLabel: t('pagination.perPage', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drawer Group - only render when docForm is available */}
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
DrawerPortal,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Pagination } from '@langgenius/dify-ui/pagination'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
@ -25,7 +26,6 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import FloatRightContainer from '@/app/components/base/float-right-container'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import docStyle from '@/app/components/datasets/documents/detail/completed/style.module.css'
|
||||
import DatasetDetailContext from '@/context/dataset-detail'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
@ -63,6 +63,7 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
const { data: recordsRes, refetch: recordsRefetch, isLoading: isRecordsLoading } = useDatasetTestingRecords(datasetId, { limit, page: currPage + 1 })
|
||||
|
||||
const total = recordsRes?.total || 0
|
||||
const totalPages = total ? Math.max(Math.ceil(total / limit), 1) : 1
|
||||
|
||||
const { dataset: currentDataset } = useContext(DatasetDetailContext)
|
||||
const isExternal = currentDataset?.provider === 'external'
|
||||
@ -151,7 +152,19 @@ const HitTestingPage: FC<Props> = ({ datasetId }: Props) => {
|
||||
<>
|
||||
<Records records={recordsRes?.data} onClickRecord={handleClickRecord} />
|
||||
{(total && total > limit)
|
||||
? <Pagination current={currPage} onChange={setCurrPage} total={total} limit={limit} />
|
||||
? (
|
||||
<Pagination
|
||||
page={currPage + 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={page => setCurrPage(page - 1)}
|
||||
labels={{
|
||||
previous: t('pagination.previous', { ns: 'common' }),
|
||||
next: t('pagination.next', { ns: 'common' }),
|
||||
editPageNumber: (page, totalPages) => t('pagination.editPageNumber', { ns: 'common', page, totalPages }),
|
||||
pageNumberInput: t('pagination.pageNumber', { ns: 'common' }),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "تصفح",
|
||||
"imageInput.dropImageHere": "أسقط صورتك هنا، أو",
|
||||
"imageInput.supportedFormats": "يدعم PNG و JPG و JPEG و WEBP و GIF",
|
||||
"imageUploader.imageList": "قائمة الصور",
|
||||
"imageUploader.imageUpload": "تحميل الصورة",
|
||||
"imageUploader.pasteImageLink": "لصق رابط الصورة",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "لصق رابط الصورة هنا",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "موافق",
|
||||
"operation.openInNewTab": "فتح في علامة تبويب جديدة",
|
||||
"operation.params": "معلمات",
|
||||
"operation.pause": "إيقاف مؤقت",
|
||||
"operation.play": "تشغيل",
|
||||
"operation.refresh": "إعادة تشغيل",
|
||||
"operation.regenerate": "إعادة إنشاء",
|
||||
"operation.reload": "إعادة تحميل",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "إعادة تسمية",
|
||||
"operation.reset": "إعادة تعيين",
|
||||
"operation.resetKeywords": "إعادة تعيين الكلمات الرئيسية",
|
||||
"operation.retry": "إعادة المحاولة",
|
||||
"operation.save": "حفظ",
|
||||
"operation.saveAndEnable": "حفظ وتمكين",
|
||||
"operation.saveAndRegenerate": "حفظ وإعادة إنشاء القطع الفرعية",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "تخطي",
|
||||
"operation.submit": "إرسال",
|
||||
"operation.sure": "أنا متأكد",
|
||||
"operation.toggleFullscreen": "تبديل ملء الشاشة",
|
||||
"operation.toggleMute": "تبديل كتم الصوت",
|
||||
"operation.view": "عرض",
|
||||
"operation.viewDetails": "عرض التفاصيل",
|
||||
"operation.viewMore": "عرض المزيد",
|
||||
"operation.yes": "نعم",
|
||||
"operation.zoomIn": "تكبير",
|
||||
"operation.zoomOut": "تصغير",
|
||||
"pagination.editPageNumber": "تعديل رقم الصفحة، الصفحة الحالية {{page}} من {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "عناصر لكل صفحة",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "يرجى الإدخال",
|
||||
"placeholder.search": "بحث...",
|
||||
"placeholder.select": "يرجى التحديد",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "التحويل إلى نص...",
|
||||
"voiceInput.notAllow": "الميكروفون غير مصرح به",
|
||||
"voiceInput.speaking": "تحدث الآن...",
|
||||
"voiceInput.start": "إدخال صوتي",
|
||||
"you": "أنت"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "blättern",
|
||||
"imageInput.dropImageHere": "Laden Sie Ihr Bild hierher hoch oder",
|
||||
"imageInput.supportedFormats": "Unterstützt PNG, JPG, JPEG, WEBP und GIF",
|
||||
"imageUploader.imageList": "Bilderliste",
|
||||
"imageUploader.imageUpload": "Bild-Upload",
|
||||
"imageUploader.pasteImageLink": "Bildlink einfügen",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Bildlink hier einfügen",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "In neuem Tab öffnen",
|
||||
"operation.params": "Parameter",
|
||||
"operation.pause": "Pausieren",
|
||||
"operation.play": "Abspielen",
|
||||
"operation.refresh": "Neustart",
|
||||
"operation.regenerate": "Erneuern",
|
||||
"operation.reload": "Neu laden",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Umbenennen",
|
||||
"operation.reset": "Zurücksetzen",
|
||||
"operation.resetKeywords": "Schlüsselwörter zurücksetzen",
|
||||
"operation.retry": "Erneut versuchen",
|
||||
"operation.save": "Speichern",
|
||||
"operation.saveAndEnable": "Speichern und Aktivieren",
|
||||
"operation.saveAndRegenerate": "Speichern und Regenerieren von untergeordneten Chunks",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Schiff",
|
||||
"operation.submit": "Senden",
|
||||
"operation.sure": "Ich bin sicher",
|
||||
"operation.toggleFullscreen": "Vollbild umschalten",
|
||||
"operation.toggleMute": "Stummschaltung umschalten",
|
||||
"operation.view": "Ansehen",
|
||||
"operation.viewDetails": "Details anzeigen",
|
||||
"operation.viewMore": "MEHR SEHEN",
|
||||
"operation.yes": "Ja",
|
||||
"operation.zoomIn": "Vergrößern",
|
||||
"operation.zoomOut": "Verkleinern",
|
||||
"pagination.editPageNumber": "Seitennummer bearbeiten, aktuelle Seite {{page}} von {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Artikel pro Seite",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Bitte eingeben",
|
||||
"placeholder.search": "Suchen...",
|
||||
"placeholder.select": "Bitte auswählen",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Umwandlung in Text...",
|
||||
"voiceInput.notAllow": "Mikrofon nicht autorisiert",
|
||||
"voiceInput.speaking": "Sprechen Sie jetzt...",
|
||||
"voiceInput.start": "Spracheingabe",
|
||||
"you": "Du"
|
||||
}
|
||||
|
||||
@ -545,7 +545,11 @@
|
||||
"operation.yes": "Yes",
|
||||
"operation.zoomIn": "Zoom In",
|
||||
"operation.zoomOut": "Zoom Out",
|
||||
"pagination.editPageNumber": "Edit page number, current page {{page}} of {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Items per page",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Please enter",
|
||||
"placeholder.search": "Search...",
|
||||
"placeholder.select": "Please select",
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "navegar",
|
||||
"imageInput.dropImageHere": "Deja tu imagen aquí, o",
|
||||
"imageInput.supportedFormats": "Soporta PNG, JPG, JPEG, WEBP y GIF",
|
||||
"imageUploader.imageList": "Lista de imágenes",
|
||||
"imageUploader.imageUpload": "Carga de Imagen",
|
||||
"imageUploader.pasteImageLink": "Pegar enlace de imagen",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Pega el enlace de imagen aquí",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Abrir en una nueva pestaña",
|
||||
"operation.params": "Parámetros",
|
||||
"operation.pause": "Pausar",
|
||||
"operation.play": "Reproducir",
|
||||
"operation.refresh": "Reiniciar",
|
||||
"operation.regenerate": "Regenerar",
|
||||
"operation.reload": "Recargar",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Renombrar",
|
||||
"operation.reset": "Restablecer",
|
||||
"operation.resetKeywords": "Restablecer palabras clave",
|
||||
"operation.retry": "Reintentar",
|
||||
"operation.save": "Guardar",
|
||||
"operation.saveAndEnable": "Guardar y habilitar",
|
||||
"operation.saveAndRegenerate": "Guardar y regenerar fragmentos secundarios",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Navío",
|
||||
"operation.submit": "Enviar",
|
||||
"operation.sure": "Estoy seguro",
|
||||
"operation.toggleFullscreen": "Alternar pantalla completa",
|
||||
"operation.toggleMute": "Alternar silencio",
|
||||
"operation.view": "Vista",
|
||||
"operation.viewDetails": "Ver detalles",
|
||||
"operation.viewMore": "VER MÁS",
|
||||
"operation.yes": "Sí",
|
||||
"operation.zoomIn": "Acercar",
|
||||
"operation.zoomOut": "Alejar",
|
||||
"pagination.editPageNumber": "Editar número de página, página actual {{page}} de {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Elementos por página",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Por favor ingresa",
|
||||
"placeholder.search": "Buscar...",
|
||||
"placeholder.select": "Por favor selecciona",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Convirtiendo a texto...",
|
||||
"voiceInput.notAllow": "micrófono no autorizado",
|
||||
"voiceInput.speaking": "Habla ahora...",
|
||||
"voiceInput.start": "Entrada de voz",
|
||||
"you": "Tú"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "مرورگر",
|
||||
"imageInput.dropImageHere": "عکس خود را اینجا رها کنید، یا",
|
||||
"imageInput.supportedFormats": "از فرمتهای PNG، JPG، JPEG، WEBP و GIF پشتیبانی میکند",
|
||||
"imageUploader.imageList": "فهرست تصاویر",
|
||||
"imageUploader.imageUpload": "بارگذاری تصویر",
|
||||
"imageUploader.pasteImageLink": "پیوند تصویر را بچسبانید",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "پیوند تصویر را اینجا بچسبانید",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "تایید",
|
||||
"operation.openInNewTab": "باز کردن در برگه جدید",
|
||||
"operation.params": "پارامترها",
|
||||
"operation.pause": "مکث",
|
||||
"operation.play": "پخش",
|
||||
"operation.refresh": "شروع مجدد",
|
||||
"operation.regenerate": "بازسازی",
|
||||
"operation.reload": "بارگذاری مجدد",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "تغییر نام",
|
||||
"operation.reset": "بازنشانی",
|
||||
"operation.resetKeywords": "بازنشانی کلمات کلیدی",
|
||||
"operation.retry": "تلاش دوباره",
|
||||
"operation.save": "ذخیره",
|
||||
"operation.saveAndEnable": "ذخیره و فعال سازی",
|
||||
"operation.saveAndRegenerate": "ذخیره و بازسازی تکه های فرزند",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "کشتی",
|
||||
"operation.submit": "ارسال",
|
||||
"operation.sure": "مطمئن هستم",
|
||||
"operation.toggleFullscreen": "تغییر حالت تمامصفحه",
|
||||
"operation.toggleMute": "تغییر حالت بیصدا",
|
||||
"operation.view": "مشاهده",
|
||||
"operation.viewDetails": "دیدن جزئیات",
|
||||
"operation.viewMore": "بیشتر ببینید",
|
||||
"operation.yes": "بله",
|
||||
"operation.zoomIn": "بزرگنمایی",
|
||||
"operation.zoomOut": "کوچک نمایی",
|
||||
"pagination.editPageNumber": "ویرایش شماره صفحه، صفحه فعلی {{page}} از {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "موارد در هر صفحه",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "لطفا وارد کنید",
|
||||
"placeholder.search": "جستجو...",
|
||||
"placeholder.select": "لطفا انتخاب کنید",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "در حال تبدیل به متن...",
|
||||
"voiceInput.notAllow": "میکروفون مجاز نیست",
|
||||
"voiceInput.speaking": "اکنون صحبت کنید...",
|
||||
"voiceInput.start": "ورودی صوتی",
|
||||
"you": "تو"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "naviguer",
|
||||
"imageInput.dropImageHere": "Déposez votre image ici, ou",
|
||||
"imageInput.supportedFormats": "Prend en charge PNG, JPG, JPEG, WEBP et GIF",
|
||||
"imageUploader.imageList": "Liste des images",
|
||||
"imageUploader.imageUpload": "Téléchargement d'image",
|
||||
"imageUploader.pasteImageLink": "Collez le lien de l'image",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Collez le lien de l'image ici",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "D'accord",
|
||||
"operation.openInNewTab": "Ouvrir dans un nouvel onglet",
|
||||
"operation.params": "Paramètres",
|
||||
"operation.pause": "Pause",
|
||||
"operation.play": "Lire",
|
||||
"operation.refresh": "Redémarrer",
|
||||
"operation.regenerate": "Régénérer",
|
||||
"operation.reload": "Recharger",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Renommer",
|
||||
"operation.reset": "Réinitialiser",
|
||||
"operation.resetKeywords": "Réinitialiser les mots-clés",
|
||||
"operation.retry": "Réessayer",
|
||||
"operation.save": "Enregistrer",
|
||||
"operation.saveAndEnable": "Enregistrer et Activer",
|
||||
"operation.saveAndRegenerate": "Enregistrer et régénérer des morceaux enfants",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Bateau",
|
||||
"operation.submit": "Envoyer",
|
||||
"operation.sure": "Je suis sûr",
|
||||
"operation.toggleFullscreen": "Basculer en plein écran",
|
||||
"operation.toggleMute": "Activer/désactiver le son",
|
||||
"operation.view": "Vue",
|
||||
"operation.viewDetails": "Voir les détails",
|
||||
"operation.viewMore": "VOIR PLUS",
|
||||
"operation.yes": "Oui",
|
||||
"operation.zoomIn": "Zoom avant",
|
||||
"operation.zoomOut": "Zoom arrière",
|
||||
"pagination.editPageNumber": "Modifier le numéro de page, page actuelle {{page}} sur {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Articles par page",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Veuillez entrer",
|
||||
"placeholder.search": "Rechercher...",
|
||||
"placeholder.select": "Veuillez sélectionner",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Conversion en texte...",
|
||||
"voiceInput.notAllow": "microphone non autorisé",
|
||||
"voiceInput.speaking": "Parle maintenant...",
|
||||
"voiceInput.start": "Saisie vocale",
|
||||
"you": "Vous"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "ब्राउज़ करें",
|
||||
"imageInput.dropImageHere": "अपनी छवि यहाँ छोड़ें, या",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP और GIF का समर्थन करता है",
|
||||
"imageUploader.imageList": "छवि सूची",
|
||||
"imageUploader.imageUpload": "छवि अपलोड",
|
||||
"imageUploader.pasteImageLink": "छवि लिंक पेस्ट करें",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "छवि लिंक यहाँ पेस्ट करें",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "ठीक है",
|
||||
"operation.openInNewTab": "नए टैब में खोलें",
|
||||
"operation.params": "पैरामीटर",
|
||||
"operation.pause": "रोकें",
|
||||
"operation.play": "चलाएं",
|
||||
"operation.refresh": "पुनः प्रारंभ करें",
|
||||
"operation.regenerate": "पुनर्जन्म",
|
||||
"operation.reload": "पुनः लोड करें",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "नाम बदलें",
|
||||
"operation.reset": "रीसेट करें",
|
||||
"operation.resetKeywords": "कीवर्ड रीसेट करें",
|
||||
"operation.retry": "पुनः प्रयास करें",
|
||||
"operation.save": "सहेजें",
|
||||
"operation.saveAndEnable": "सहेजें और सक्षम करें",
|
||||
"operation.saveAndRegenerate": "सहेजें और पुन: उत्पन्न करें बाल विखंडू",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "जहाज़",
|
||||
"operation.submit": "जमा करें",
|
||||
"operation.sure": "मुझे यकीन है",
|
||||
"operation.toggleFullscreen": "फ़ुलस्क्रीन टॉगल करें",
|
||||
"operation.toggleMute": "म्यूट टॉगल करें",
|
||||
"operation.view": "देखना",
|
||||
"operation.viewDetails": "विवरण देखें",
|
||||
"operation.viewMore": "और देखें",
|
||||
"operation.yes": "हाँ",
|
||||
"operation.zoomIn": "ज़ूम इन करें",
|
||||
"operation.zoomOut": "ज़ूम आउट करें",
|
||||
"pagination.editPageNumber": "पृष्ठ संख्या संपादित करें, वर्तमान पृष्ठ {{page}} / {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "प्रति पृष्ठ आइटम",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "कृपया दर्ज करें",
|
||||
"placeholder.search": "खोजें...",
|
||||
"placeholder.select": "कृपया चयन करें",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "पाठ में परिवर्तित हो रहा है...",
|
||||
"voiceInput.notAllow": "माइक्रोफोन अधिकृत नहीं है",
|
||||
"voiceInput.speaking": "अब बोलें...",
|
||||
"voiceInput.start": "वॉइस इनपुट",
|
||||
"you": "आप"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "Telusuri",
|
||||
"imageInput.dropImageHere": "Letakkan gambar Anda di sini, atau",
|
||||
"imageInput.supportedFormats": "Mendukung PNG, JPG, JPEG, WEBP dan GIF",
|
||||
"imageUploader.imageList": "Daftar gambar",
|
||||
"imageUploader.imageUpload": "Unggah Gambar",
|
||||
"imageUploader.pasteImageLink": "Tempel tautan gambar",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Tempel tautan gambar di sini",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OKE",
|
||||
"operation.openInNewTab": "Buka di tab baru",
|
||||
"operation.params": "Parameter",
|
||||
"operation.pause": "Jeda",
|
||||
"operation.play": "Putar",
|
||||
"operation.refresh": "Segarkan",
|
||||
"operation.regenerate": "Regenerasi",
|
||||
"operation.reload": "Muat Ulang",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Ubah nama",
|
||||
"operation.reset": "Reset",
|
||||
"operation.resetKeywords": "Atur ulang kata kunci",
|
||||
"operation.retry": "Coba lagi",
|
||||
"operation.save": "Simpan",
|
||||
"operation.saveAndEnable": "Simpan & Aktifkan",
|
||||
"operation.saveAndRegenerate": "Simpan & Buat Ulang Potongan Anak",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Lewat",
|
||||
"operation.submit": "Kirim",
|
||||
"operation.sure": "Saya yakin",
|
||||
"operation.toggleFullscreen": "Alihkan layar penuh",
|
||||
"operation.toggleMute": "Alihkan bisu",
|
||||
"operation.view": "Lihat",
|
||||
"operation.viewDetails": "Lihat Detail",
|
||||
"operation.viewMore": "LIHAT LEBIH BANYAK",
|
||||
"operation.yes": "Ya",
|
||||
"operation.zoomIn": "Perbesar",
|
||||
"operation.zoomOut": "Perkecil",
|
||||
"pagination.editPageNumber": "Edit nomor halaman, halaman saat ini {{page}} dari {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Item per halaman",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Silakan masuk",
|
||||
"placeholder.search": "Cari...",
|
||||
"placeholder.select": "Silakan pilih",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Mengonversi ke teks...",
|
||||
"voiceInput.notAllow": "mikrofon tidak diizinkan",
|
||||
"voiceInput.speaking": "Bicaralah sekarang...",
|
||||
"voiceInput.start": "Input suara",
|
||||
"you": "Kamu"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "sfogliare",
|
||||
"imageInput.dropImageHere": "Trascina la tua immagine qui, oppure",
|
||||
"imageInput.supportedFormats": "Supporta PNG, JPG, JPEG, WEBP e GIF",
|
||||
"imageUploader.imageList": "Elenco immagini",
|
||||
"imageUploader.imageUpload": "Caricamento Immagine",
|
||||
"imageUploader.pasteImageLink": "Incolla link immagine",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Incolla qui il link immagine",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Apri in una nuova scheda",
|
||||
"operation.params": "Parametri",
|
||||
"operation.pause": "Pausa",
|
||||
"operation.play": "Riproduci",
|
||||
"operation.refresh": "Riavvia",
|
||||
"operation.regenerate": "Rigenerare",
|
||||
"operation.reload": "Ricarica",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Rinomina",
|
||||
"operation.reset": "Reimposta",
|
||||
"operation.resetKeywords": "Reimposta parole chiave",
|
||||
"operation.retry": "Riprova",
|
||||
"operation.save": "Salva",
|
||||
"operation.saveAndEnable": "Salva & Abilita",
|
||||
"operation.saveAndRegenerate": "Salva e rigenera i blocchi figlio",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Nave",
|
||||
"operation.submit": "Invia",
|
||||
"operation.sure": "Sono sicuro",
|
||||
"operation.toggleFullscreen": "Attiva/disattiva schermo intero",
|
||||
"operation.toggleMute": "Attiva/disattiva muto",
|
||||
"operation.view": "Vista",
|
||||
"operation.viewDetails": "Visualizza dettagli",
|
||||
"operation.viewMore": "SCOPRI DI PIÙ",
|
||||
"operation.yes": "Sì",
|
||||
"operation.zoomIn": "Ingrandisci",
|
||||
"operation.zoomOut": "Zoom indietro",
|
||||
"pagination.editPageNumber": "Modifica numero pagina, pagina corrente {{page}} di {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Articoli per pagina",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Per favore inserisci",
|
||||
"placeholder.search": "Cerca...",
|
||||
"placeholder.select": "Per favore seleziona",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Conversione in testo...",
|
||||
"voiceInput.notAllow": "microfono non autorizzato",
|
||||
"voiceInput.speaking": "Parla ora...",
|
||||
"voiceInput.start": "Input vocale",
|
||||
"you": "Tu"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "ブラウズする",
|
||||
"imageInput.dropImageHere": "ここに画像をドロップするか、",
|
||||
"imageInput.supportedFormats": "PNG、JPG、JPEG、WEBP、および GIF をサポートしています。",
|
||||
"imageUploader.imageList": "画像リスト",
|
||||
"imageUploader.imageUpload": "画像アップロード",
|
||||
"imageUploader.pasteImageLink": "画像リンクを貼り付ける",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "ここに画像リンクを貼り付けてください",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "新しいタブで開く",
|
||||
"operation.params": "パラメータ",
|
||||
"operation.pause": "一時停止",
|
||||
"operation.play": "再生",
|
||||
"operation.refresh": "リフレッシュ",
|
||||
"operation.regenerate": "再生成",
|
||||
"operation.reload": "再読み込み",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "名前の変更",
|
||||
"operation.reset": "リセット",
|
||||
"operation.resetKeywords": "キーワードをリセット",
|
||||
"operation.retry": "再試行",
|
||||
"operation.save": "保存",
|
||||
"operation.saveAndEnable": "保存 & 有効に",
|
||||
"operation.saveAndRegenerate": "保存して子チャンクを再生成",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "スキップ",
|
||||
"operation.submit": "送信",
|
||||
"operation.sure": "確認済み",
|
||||
"operation.toggleFullscreen": "全画面表示を切り替え",
|
||||
"operation.toggleMute": "ミュートを切り替え",
|
||||
"operation.view": "表示",
|
||||
"operation.viewDetails": "詳細を見る",
|
||||
"operation.viewMore": "さらに表示",
|
||||
"operation.yes": "はい",
|
||||
"operation.zoomIn": "ズームインする",
|
||||
"operation.zoomOut": "ズームアウト",
|
||||
"pagination.editPageNumber": "ページ番号を編集、現在のページ {{page}} / {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "ページあたりのアイテム数",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "入力してください",
|
||||
"placeholder.search": "検索...",
|
||||
"placeholder.select": "選択してください",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "テキストに変換中...",
|
||||
"voiceInput.notAllow": "マイクが許可されていません",
|
||||
"voiceInput.speaking": "今話しています...",
|
||||
"voiceInput.start": "音声入力",
|
||||
"you": "あなた"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "찾아보기",
|
||||
"imageInput.dropImageHere": "여기에 이미지를 드롭하거나",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP 및 GIF 를 지원합니다.",
|
||||
"imageUploader.imageList": "이미지 목록",
|
||||
"imageUploader.imageUpload": "이미지 업로드",
|
||||
"imageUploader.pasteImageLink": "이미지 링크 붙여넣기",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "여기에 이미지 링크를 붙여넣으세요",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "확인",
|
||||
"operation.openInNewTab": "새 탭에서 열기",
|
||||
"operation.params": "매개변수",
|
||||
"operation.pause": "일시 중지",
|
||||
"operation.play": "재생",
|
||||
"operation.refresh": "새로 고침",
|
||||
"operation.regenerate": "재생성",
|
||||
"operation.reload": "다시 불러오기",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "이름 바꾸기",
|
||||
"operation.reset": "초기화",
|
||||
"operation.resetKeywords": "키워드 재설정",
|
||||
"operation.retry": "다시 시도",
|
||||
"operation.save": "저장",
|
||||
"operation.saveAndEnable": "저장 및 활성화",
|
||||
"operation.saveAndRegenerate": "저장 및 자식 청크 재생성",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "건너뛰기",
|
||||
"operation.submit": "전송",
|
||||
"operation.sure": "확인",
|
||||
"operation.toggleFullscreen": "전체 화면 전환",
|
||||
"operation.toggleMute": "음소거 전환",
|
||||
"operation.view": "보기",
|
||||
"operation.viewDetails": "세부 정보보기",
|
||||
"operation.viewMore": "더보기",
|
||||
"operation.yes": "네",
|
||||
"operation.zoomIn": "확대",
|
||||
"operation.zoomOut": "축소",
|
||||
"pagination.editPageNumber": "페이지 번호 편집, 현재 {{page}} / {{totalPages}} 페이지",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "페이지당 항목 수",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "입력해주세요",
|
||||
"placeholder.search": "검색...",
|
||||
"placeholder.select": "선택해주세요",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "텍스트로 변환 중...",
|
||||
"voiceInput.notAllow": "마이크가 허용되지 않았습니다",
|
||||
"voiceInput.speaking": "지금 말하고 있습니다...",
|
||||
"voiceInput.start": "음성 입력",
|
||||
"you": "나"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "browse",
|
||||
"imageInput.dropImageHere": "Drop your image here, or",
|
||||
"imageInput.supportedFormats": "Supports PNG, JPG, JPEG, WEBP and GIF",
|
||||
"imageUploader.imageList": "Afbeeldingenlijst",
|
||||
"imageUploader.imageUpload": "Image Upload",
|
||||
"imageUploader.pasteImageLink": "Paste image link",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Paste image link here",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Open in new tab",
|
||||
"operation.params": "Params",
|
||||
"operation.pause": "Pauzeren",
|
||||
"operation.play": "Afspelen",
|
||||
"operation.refresh": "Restart",
|
||||
"operation.regenerate": "Regenerate",
|
||||
"operation.reload": "Reload",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Rename",
|
||||
"operation.reset": "Reset",
|
||||
"operation.resetKeywords": "Reset keywords",
|
||||
"operation.retry": "Opnieuw proberen",
|
||||
"operation.save": "Save",
|
||||
"operation.saveAndEnable": "Save & Enable",
|
||||
"operation.saveAndRegenerate": "Save & Regenerate Child Chunks",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Skip",
|
||||
"operation.submit": "Submit",
|
||||
"operation.sure": "I'm sure",
|
||||
"operation.toggleFullscreen": "Volledig scherm schakelen",
|
||||
"operation.toggleMute": "Dempen schakelen",
|
||||
"operation.view": "View",
|
||||
"operation.viewDetails": "View Details",
|
||||
"operation.viewMore": "VIEW MORE",
|
||||
"operation.yes": "Yes",
|
||||
"operation.zoomIn": "Zoom In",
|
||||
"operation.zoomOut": "Zoom Out",
|
||||
"pagination.editPageNumber": "Paginanummer bewerken, huidige pagina {{page}} van {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Items per page",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Please enter",
|
||||
"placeholder.search": "Search...",
|
||||
"placeholder.select": "Please select",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Converting to text...",
|
||||
"voiceInput.notAllow": "microphone not authorized",
|
||||
"voiceInput.speaking": "Speak now...",
|
||||
"voiceInput.start": "Spraakinvoer",
|
||||
"you": "You"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "przeglądaj",
|
||||
"imageInput.dropImageHere": "Upuść swój obraz tutaj, lub",
|
||||
"imageInput.supportedFormats": "Obsługuje PNG, JPG, JPEG, WEBP i GIF",
|
||||
"imageUploader.imageList": "Lista obrazów",
|
||||
"imageUploader.imageUpload": "Przesyłanie obrazu",
|
||||
"imageUploader.pasteImageLink": "Wklej link do obrazu",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Wklej tutaj link do obrazu",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Otwórz w nowej karcie",
|
||||
"operation.params": "Parametry",
|
||||
"operation.pause": "Pauza",
|
||||
"operation.play": "Odtwórz",
|
||||
"operation.refresh": "Odśwież",
|
||||
"operation.regenerate": "Ponownie wygenerować",
|
||||
"operation.reload": "Przeładuj",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Zmień nazwę",
|
||||
"operation.reset": "Resetuj",
|
||||
"operation.resetKeywords": "Resetuj słowa kluczowe",
|
||||
"operation.retry": "Spróbuj ponownie",
|
||||
"operation.save": "Zapisz",
|
||||
"operation.saveAndEnable": "Zapisz i Włącz",
|
||||
"operation.saveAndRegenerate": "Zapisywanie i regeneracja fragmentów podrzędnych",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Statek",
|
||||
"operation.submit": "Prześlij",
|
||||
"operation.sure": "Jestem pewien",
|
||||
"operation.toggleFullscreen": "Przełącz pełny ekran",
|
||||
"operation.toggleMute": "Przełącz wyciszenie",
|
||||
"operation.view": "Widok",
|
||||
"operation.viewDetails": "Wyświetl szczegóły",
|
||||
"operation.viewMore": "ZOBACZ WIĘCEJ",
|
||||
"operation.yes": "Tak",
|
||||
"operation.zoomIn": "Powiększenie",
|
||||
"operation.zoomOut": "Pomniejszanie",
|
||||
"pagination.editPageNumber": "Edytuj numer strony, bieżąca strona {{page}} z {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Ilość elementów na stronie",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Proszę wprowadzić",
|
||||
"placeholder.search": "Szukaj...",
|
||||
"placeholder.select": "Proszę wybrać",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Konwertowanie na tekst...",
|
||||
"voiceInput.notAllow": "mikrofon nieautoryzowany",
|
||||
"voiceInput.speaking": "Mów teraz...",
|
||||
"voiceInput.start": "Wprowadzanie głosowe",
|
||||
"you": "Ty"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "navegar",
|
||||
"imageInput.dropImageHere": "Arraste sua imagem aqui, ou",
|
||||
"imageInput.supportedFormats": "Suporta PNG, JPG, JPEG, WEBP e GIF",
|
||||
"imageUploader.imageList": "Lista de imagens",
|
||||
"imageUploader.imageUpload": "Enviar Imagem",
|
||||
"imageUploader.pasteImageLink": "Colar link da imagem",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Cole o link da imagem aqui",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Abrir em nova guia",
|
||||
"operation.params": "Parâmetros",
|
||||
"operation.pause": "Pausar",
|
||||
"operation.play": "Reproduzir",
|
||||
"operation.refresh": "Reiniciar",
|
||||
"operation.regenerate": "Regenerar",
|
||||
"operation.reload": "Recarregar",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Renomear",
|
||||
"operation.reset": "Redefinir",
|
||||
"operation.resetKeywords": "Redefinir palavras-chave",
|
||||
"operation.retry": "Tentar novamente",
|
||||
"operation.save": "Salvar",
|
||||
"operation.saveAndEnable": "Salvar e Ativar",
|
||||
"operation.saveAndRegenerate": "Salvar e regenerar pedaços filhos",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Navio",
|
||||
"operation.submit": "Enviar",
|
||||
"operation.sure": "Tenho certeza",
|
||||
"operation.toggleFullscreen": "Alternar tela cheia",
|
||||
"operation.toggleMute": "Alternar mudo",
|
||||
"operation.view": "Vista",
|
||||
"operation.viewDetails": "Ver detalhes",
|
||||
"operation.viewMore": "VER MAIS",
|
||||
"operation.yes": "Sim",
|
||||
"operation.zoomIn": "Ampliar",
|
||||
"operation.zoomOut": "Diminuir o zoom",
|
||||
"pagination.editPageNumber": "Editar número da página, página atual {{page}} de {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Itens por página",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Por favor, insira",
|
||||
"placeholder.search": "Pesquisar...",
|
||||
"placeholder.select": "Por favor, selecione",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Convertendo para texto...",
|
||||
"voiceInput.notAllow": "microfone não autorizado",
|
||||
"voiceInput.speaking": "Fale agora...",
|
||||
"voiceInput.start": "Entrada por voz",
|
||||
"you": "Você"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "naviga",
|
||||
"imageInput.dropImageHere": "Trageți imaginea aici sau",
|
||||
"imageInput.supportedFormats": "Suportă PNG, JPG, JPEG, WEBP și GIF",
|
||||
"imageUploader.imageList": "Listă de imagini",
|
||||
"imageUploader.imageUpload": "Încărcare imagine",
|
||||
"imageUploader.pasteImageLink": "Inserați link-ul imaginii",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Inserați link-ul imaginii aici",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Deschide într-o filă nouă",
|
||||
"operation.params": "Parametri",
|
||||
"operation.pause": "Pauză",
|
||||
"operation.play": "Redare",
|
||||
"operation.refresh": "Reîncarcă",
|
||||
"operation.regenerate": "Regenera",
|
||||
"operation.reload": "Reîncarcă",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Redenumește",
|
||||
"operation.reset": "Resetează",
|
||||
"operation.resetKeywords": "Resetează cuvintele cheie",
|
||||
"operation.retry": "Reîncercați",
|
||||
"operation.save": "Salvează",
|
||||
"operation.saveAndEnable": "Salvează și Activează",
|
||||
"operation.saveAndRegenerate": "Salvați și regenerați bucățile secundare",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Navă",
|
||||
"operation.submit": "Prezinte",
|
||||
"operation.sure": "Sunt sigur",
|
||||
"operation.toggleFullscreen": "Comută ecran complet",
|
||||
"operation.toggleMute": "Comută sunetul",
|
||||
"operation.view": "Vedere",
|
||||
"operation.viewDetails": "Vezi detalii",
|
||||
"operation.viewMore": "VEZI MAI MULT",
|
||||
"operation.yes": "Da",
|
||||
"operation.zoomIn": "Măriți",
|
||||
"operation.zoomOut": "Micșorare",
|
||||
"pagination.editPageNumber": "Editați numărul paginii, pagina curentă {{page}} din {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Articole pe pagină",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Vă rugăm să introduceți",
|
||||
"placeholder.search": "Caută...",
|
||||
"placeholder.select": "Vă rugăm să selectați",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Se convertește la text...",
|
||||
"voiceInput.notAllow": "microfonul nu este autorizat",
|
||||
"voiceInput.speaking": "Vorbiți acum...",
|
||||
"voiceInput.start": "Introducere vocală",
|
||||
"you": "Tu"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "просмотр",
|
||||
"imageInput.dropImageHere": "Перетащите ваше изображение сюда или",
|
||||
"imageInput.supportedFormats": "Поддерживает PNG, JPG, JPEG, WEBP и GIF",
|
||||
"imageUploader.imageList": "Список изображений",
|
||||
"imageUploader.imageUpload": "Загрузка изображения",
|
||||
"imageUploader.pasteImageLink": "Вставить ссылку на изображение",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Вставьте ссылку на изображение здесь",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "ОК",
|
||||
"operation.openInNewTab": "Открыть в новой вкладке",
|
||||
"operation.params": "Параметры",
|
||||
"operation.pause": "Пауза",
|
||||
"operation.play": "Воспроизвести",
|
||||
"operation.refresh": "Перезапустить",
|
||||
"operation.regenerate": "Регенерировать",
|
||||
"operation.reload": "Перезагрузить",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Переименовать",
|
||||
"operation.reset": "Сбросить",
|
||||
"operation.resetKeywords": "Сбросить ключевые слова",
|
||||
"operation.retry": "Повторить",
|
||||
"operation.save": "Сохранить",
|
||||
"operation.saveAndEnable": "Сохранить и включить",
|
||||
"operation.saveAndRegenerate": "Сохранение и повторное создание дочерних блоков",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Корабль",
|
||||
"operation.submit": "Отправить",
|
||||
"operation.sure": "Я уверен",
|
||||
"operation.toggleFullscreen": "Переключить полноэкранный режим",
|
||||
"operation.toggleMute": "Переключить звук",
|
||||
"operation.view": "Вид",
|
||||
"operation.viewDetails": "Подробнее",
|
||||
"operation.viewMore": "ПОДРОБНЕЕ",
|
||||
"operation.yes": "Да",
|
||||
"operation.zoomIn": "Увеличить",
|
||||
"operation.zoomOut": "Уменьшение масштаба",
|
||||
"pagination.editPageNumber": "Изменить номер страницы, текущая страница {{page}} из {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Элементов на странице",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Пожалуйста, введите",
|
||||
"placeholder.search": "Поиск...",
|
||||
"placeholder.select": "Пожалуйста, выберите",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Преобразование в текст...",
|
||||
"voiceInput.notAllow": "микрофон не авторизован",
|
||||
"voiceInput.speaking": "Говорите сейчас...",
|
||||
"voiceInput.start": "Голосовой ввод",
|
||||
"you": "Ты"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "brskati",
|
||||
"imageInput.dropImageHere": "Tukaj spustite svojo sliko ali",
|
||||
"imageInput.supportedFormats": "Podpira PNG, JPG, JPEG, WEBP in GIF",
|
||||
"imageUploader.imageList": "Seznam slik",
|
||||
"imageUploader.imageUpload": "Nalaganje slik",
|
||||
"imageUploader.pasteImageLink": "Prilepi povezavo do slike",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Tukaj prilepi povezavo do slike",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "V redu",
|
||||
"operation.openInNewTab": "Odpri v novem zavihku",
|
||||
"operation.params": "Parametri",
|
||||
"operation.pause": "Premor",
|
||||
"operation.play": "Predvajaj",
|
||||
"operation.refresh": "Osveži",
|
||||
"operation.regenerate": "Regeneracijo",
|
||||
"operation.reload": "Ponovno naloži",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Preimenuj",
|
||||
"operation.reset": "Ponastavi",
|
||||
"operation.resetKeywords": "Ponastavi ključne besede",
|
||||
"operation.retry": "Poskusi znova",
|
||||
"operation.save": "Shrani",
|
||||
"operation.saveAndEnable": "Shrani in omogoči",
|
||||
"operation.saveAndRegenerate": "Shranite in regenerirajte otroške koščke",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Ladja",
|
||||
"operation.submit": "Predložiti",
|
||||
"operation.sure": "Prepričan sem",
|
||||
"operation.toggleFullscreen": "Preklopi celozaslonski način",
|
||||
"operation.toggleMute": "Preklopi utišanje",
|
||||
"operation.view": "Pogled",
|
||||
"operation.viewDetails": "Poglej podrobnosti",
|
||||
"operation.viewMore": "POGLEJ VEČ",
|
||||
"operation.yes": "Da",
|
||||
"operation.zoomIn": "Povečava",
|
||||
"operation.zoomOut": "Pomanjšanje",
|
||||
"pagination.editPageNumber": "Uredi številko strani, trenutna stran {{page}} od {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Elementi na stran",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Vnesite prosim",
|
||||
"placeholder.search": "Išči...",
|
||||
"placeholder.select": "Izberite prosim",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Pretvorba v besedilo ...",
|
||||
"voiceInput.notAllow": "Mikrofon ni pooblaščen",
|
||||
"voiceInput.speaking": "Spregovorite zdaj ...",
|
||||
"voiceInput.start": "Glasovni vnos",
|
||||
"you": "Ti"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "ท่องเว็บ",
|
||||
"imageInput.dropImageHere": "วางภาพของคุณที่นี่ หรือ",
|
||||
"imageInput.supportedFormats": "รองรับ PNG, JPG, JPEG, WEBP และ GIF",
|
||||
"imageUploader.imageList": "รายการรูปภาพ",
|
||||
"imageUploader.imageUpload": "อัปโหลดรูปภาพ",
|
||||
"imageUploader.pasteImageLink": "วางลิงก์รูปภาพ",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "วางลิงค์รูปภาพที่นี่",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "ตกลง, ได้",
|
||||
"operation.openInNewTab": "เปิดในแท็บใหม่",
|
||||
"operation.params": "พารามิเตอร์",
|
||||
"operation.pause": "หยุดชั่วคราว",
|
||||
"operation.play": "เล่น",
|
||||
"operation.refresh": "เริ่มใหม่",
|
||||
"operation.regenerate": "สร้างใหม่",
|
||||
"operation.reload": "โหลด",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "ตั้งชื่อใหม่",
|
||||
"operation.reset": "รี เซ็ต",
|
||||
"operation.resetKeywords": "รีเซ็ตคำสำคัญ",
|
||||
"operation.retry": "ลองอีกครั้ง",
|
||||
"operation.save": "ประหยัด",
|
||||
"operation.saveAndEnable": "บันทึกและเปิดใช้งาน",
|
||||
"operation.saveAndRegenerate": "บันทึกและสร้างก้อนย่อยใหม่",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "เรือ",
|
||||
"operation.submit": "ส่ง",
|
||||
"operation.sure": "ฉันแน่ใจ",
|
||||
"operation.toggleFullscreen": "สลับเต็มหน้าจอ",
|
||||
"operation.toggleMute": "สลับปิดเสียง",
|
||||
"operation.view": "ทิวทัศน์",
|
||||
"operation.viewDetails": "ดูรายละเอียด",
|
||||
"operation.viewMore": "ดูเพิ่มเติม",
|
||||
"operation.yes": "ใช่",
|
||||
"operation.zoomIn": "ซูมเข้า",
|
||||
"operation.zoomOut": "ซูมออก",
|
||||
"pagination.editPageNumber": "แก้ไขหมายเลขหน้า หน้าปัจจุบัน {{page}} จาก {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "รายการต่อหน้า",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "กรุณากรอก",
|
||||
"placeholder.search": "ค้นหา...",
|
||||
"placeholder.select": "กรุณาเลือก",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "กําลังแปลงเป็นข้อความ...",
|
||||
"voiceInput.notAllow": "ไม่ได้รับอนุญาตไมโครโฟน",
|
||||
"voiceInput.speaking": "พูดเดี๋ยวนี้...",
|
||||
"voiceInput.start": "ป้อนข้อมูลด้วยเสียง",
|
||||
"you": "คุณ"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "göz atın",
|
||||
"imageInput.dropImageHere": "Görüntünüzü buraya bırakın veya",
|
||||
"imageInput.supportedFormats": "PNG, JPG, JPEG, WEBP ve GIF'i destekler",
|
||||
"imageUploader.imageList": "Görsel listesi",
|
||||
"imageUploader.imageUpload": "Görüntü Yükleme",
|
||||
"imageUploader.pasteImageLink": "Görüntü bağlantısını yapıştır",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Görüntü bağlantısını buraya yapıştırın",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "Tamam",
|
||||
"operation.openInNewTab": "Yeni sekmede aç",
|
||||
"operation.params": "Parametreler",
|
||||
"operation.pause": "Duraklat",
|
||||
"operation.play": "Oynat",
|
||||
"operation.refresh": "Yeniden Başlat",
|
||||
"operation.regenerate": "Yeniden Oluştur",
|
||||
"operation.reload": "Yeniden Yükle",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Yeniden Adlandır",
|
||||
"operation.reset": "Sıfırla",
|
||||
"operation.resetKeywords": "Anahtar kelimeleri sıfırla",
|
||||
"operation.retry": "Tekrar dene",
|
||||
"operation.save": "Kaydet",
|
||||
"operation.saveAndEnable": "Kaydet ve Etkinleştir",
|
||||
"operation.saveAndRegenerate": "Alt Parçaları Kaydetme ve Yeniden Oluşturma",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Atla",
|
||||
"operation.submit": "Gönder",
|
||||
"operation.sure": "Eminim",
|
||||
"operation.toggleFullscreen": "Tam ekranı aç/kapat",
|
||||
"operation.toggleMute": "Sessize al/aç",
|
||||
"operation.view": "Görüntüle",
|
||||
"operation.viewDetails": "Detayları Görüntüle",
|
||||
"operation.viewMore": "DAHA FAZLA GÖSTER",
|
||||
"operation.yes": "Evet",
|
||||
"operation.zoomIn": "Yakınlaştırma",
|
||||
"operation.zoomOut": "Uzaklaştırma",
|
||||
"pagination.editPageNumber": "Sayfa numarasını düzenle, geçerli sayfa {{page}} / {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Sayfa başına öğe sayısı",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Lütfen girin",
|
||||
"placeholder.search": "Ara...",
|
||||
"placeholder.select": "Lütfen seçin",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Metne dönüştürülüyor...",
|
||||
"voiceInput.notAllow": "mikrofon yetkilendirilmedi",
|
||||
"voiceInput.speaking": "Şimdi konuş...",
|
||||
"voiceInput.start": "Sesli giriş",
|
||||
"you": "Sen"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "перегляд",
|
||||
"imageInput.dropImageHere": "Перетягніть зображення сюди або",
|
||||
"imageInput.supportedFormats": "Підтримує PNG, JPG, JPEG, WEBP і GIF",
|
||||
"imageUploader.imageList": "Список зображень",
|
||||
"imageUploader.imageUpload": "Завантаження зображення",
|
||||
"imageUploader.pasteImageLink": "Вставити посилання на зображення",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Вставте посилання на зображення тут",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "ОК",
|
||||
"operation.openInNewTab": "Відкрити в новій вкладці",
|
||||
"operation.params": "Параметри",
|
||||
"operation.pause": "Пауза",
|
||||
"operation.play": "Відтворити",
|
||||
"operation.refresh": "Перезапустити",
|
||||
"operation.regenerate": "Відновити",
|
||||
"operation.reload": "Перезавантажити",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Перейменувати",
|
||||
"operation.reset": "Скинути",
|
||||
"operation.resetKeywords": "Скинути ключові слова",
|
||||
"operation.retry": "Повторити",
|
||||
"operation.save": "Зберегти",
|
||||
"operation.saveAndEnable": "Зберегти та Увімкнути",
|
||||
"operation.saveAndRegenerate": "Збереження та регенерація дочірніх фрагментів",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Корабель",
|
||||
"operation.submit": "Представити",
|
||||
"operation.sure": "Я впевнений",
|
||||
"operation.toggleFullscreen": "Перемкнути повноекранний режим",
|
||||
"operation.toggleMute": "Перемкнути звук",
|
||||
"operation.view": "Вид",
|
||||
"operation.viewDetails": "Перегляд докладних відомостей",
|
||||
"operation.viewMore": "ДИВИТИСЬ БІЛЬШЕ",
|
||||
"operation.yes": "Так",
|
||||
"operation.zoomIn": "Збільшити масштаб",
|
||||
"operation.zoomOut": "Зменшити масштаб",
|
||||
"pagination.editPageNumber": "Редагувати номер сторінки, поточна сторінка {{page}} з {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Елементів на сторінці",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Будь ласка, введіть текст",
|
||||
"placeholder.search": "Пошук...",
|
||||
"placeholder.select": "Будь ласка, оберіть параметр",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Перетворення на текст...",
|
||||
"voiceInput.notAllow": "мікрофон не авторизований",
|
||||
"voiceInput.speaking": "Говоріть зараз...",
|
||||
"voiceInput.start": "Голосове введення",
|
||||
"you": "Ти"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "duyệt",
|
||||
"imageInput.dropImageHere": "Kéo hình ảnh của bạn vào đây, hoặc",
|
||||
"imageInput.supportedFormats": "Hỗ trợ PNG, JPG, JPEG, WEBP và GIF",
|
||||
"imageUploader.imageList": "Danh sách hình ảnh",
|
||||
"imageUploader.imageUpload": "Tải ảnh lên",
|
||||
"imageUploader.pasteImageLink": "Dán liên kết ảnh",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "Dán liên kết ảnh ở đây",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "OK",
|
||||
"operation.openInNewTab": "Mở trong tab mới",
|
||||
"operation.params": "Tham số",
|
||||
"operation.pause": "Tạm dừng",
|
||||
"operation.play": "Phát",
|
||||
"operation.refresh": "Làm mới",
|
||||
"operation.regenerate": "Tái tạo",
|
||||
"operation.reload": "Tải lại",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "Đổi tên",
|
||||
"operation.reset": "Đặt lại",
|
||||
"operation.resetKeywords": "Đặt lại từ khóa",
|
||||
"operation.retry": "Thử lại",
|
||||
"operation.save": "Lưu",
|
||||
"operation.saveAndEnable": "Lưu & Kích hoạt",
|
||||
"operation.saveAndRegenerate": "Lưu và tạo lại các phần con",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "Tàu",
|
||||
"operation.submit": "Trình",
|
||||
"operation.sure": "Tôi chắc chắn",
|
||||
"operation.toggleFullscreen": "Chuyển đổi toàn màn hình",
|
||||
"operation.toggleMute": "Bật/tắt tiếng",
|
||||
"operation.view": "Cảnh",
|
||||
"operation.viewDetails": "Xem chi tiết",
|
||||
"operation.viewMore": "XEM THÊM",
|
||||
"operation.yes": "Vâng",
|
||||
"operation.zoomIn": "Phóng to",
|
||||
"operation.zoomOut": "Thu nhỏ",
|
||||
"pagination.editPageNumber": "Chỉnh sửa số trang, trang hiện tại {{page}} trên {{totalPages}}",
|
||||
"pagination.next": "Next page",
|
||||
"pagination.pageNumber": "Page number",
|
||||
"pagination.perPage": "Mục trên mỗi trang",
|
||||
"pagination.previous": "Previous page",
|
||||
"placeholder.input": "Vui lòng nhập",
|
||||
"placeholder.search": "Tìm kiếm...",
|
||||
"placeholder.select": "Vui lòng chọn",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "Chuyển đổi thành văn bản...",
|
||||
"voiceInput.notAllow": "micro không được ủy quyền",
|
||||
"voiceInput.speaking": "Hãy nói...",
|
||||
"voiceInput.start": "Nhập bằng giọng nói",
|
||||
"you": "Bạn"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "浏览",
|
||||
"imageInput.dropImageHere": "将图片拖放到此处,或",
|
||||
"imageInput.supportedFormats": "支持 PNG、JPG、JPEG、WEBP 和 GIF 格式",
|
||||
"imageUploader.imageList": "图片列表",
|
||||
"imageUploader.imageUpload": "图片上传",
|
||||
"imageUploader.pasteImageLink": "粘贴图片链接",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "将图像链接粘贴到此处",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "好的",
|
||||
"operation.openInNewTab": "在新标签页打开",
|
||||
"operation.params": "参数设置",
|
||||
"operation.pause": "暂停",
|
||||
"operation.play": "播放",
|
||||
"operation.refresh": "重新开始",
|
||||
"operation.regenerate": "重新生成",
|
||||
"operation.reload": "刷新",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "重命名",
|
||||
"operation.reset": "重置",
|
||||
"operation.resetKeywords": "重置关键词",
|
||||
"operation.retry": "重试",
|
||||
"operation.save": "保存",
|
||||
"operation.saveAndEnable": "保存并启用",
|
||||
"operation.saveAndRegenerate": "保存并重新生成子分段",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "跳过",
|
||||
"operation.submit": "提交",
|
||||
"operation.sure": "我确定",
|
||||
"operation.toggleFullscreen": "切换全屏",
|
||||
"operation.toggleMute": "切换静音",
|
||||
"operation.view": "查看",
|
||||
"operation.viewDetails": "查看详情",
|
||||
"operation.viewMore": "查看更多",
|
||||
"operation.yes": "是",
|
||||
"operation.zoomIn": "放大",
|
||||
"operation.zoomOut": "缩小",
|
||||
"pagination.editPageNumber": "编辑页码,当前第 {{page}} 页,共 {{totalPages}} 页",
|
||||
"pagination.next": "下一页",
|
||||
"pagination.pageNumber": "页码",
|
||||
"pagination.perPage": "每页显示",
|
||||
"pagination.previous": "上一页",
|
||||
"placeholder.input": "请输入",
|
||||
"placeholder.search": "搜索...",
|
||||
"placeholder.select": "请选择",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "正在转换为文本...",
|
||||
"voiceInput.notAllow": "麦克风未授权",
|
||||
"voiceInput.speaking": "现在讲...",
|
||||
"voiceInput.start": "语音输入",
|
||||
"you": "你"
|
||||
}
|
||||
|
||||
@ -194,6 +194,7 @@
|
||||
"imageInput.browse": "瀏覽",
|
||||
"imageInput.dropImageHere": "將您的圖片放在這裡,或",
|
||||
"imageInput.supportedFormats": "支援 PNG、JPG、JPEG、WEBP 和 GIF",
|
||||
"imageUploader.imageList": "圖片列表",
|
||||
"imageUploader.imageUpload": "圖片上傳",
|
||||
"imageUploader.pasteImageLink": "貼上圖片連結",
|
||||
"imageUploader.pasteImageLinkInputPlaceholder": "將影象連結貼上到此處",
|
||||
@ -512,6 +513,8 @@
|
||||
"operation.ok": "好的",
|
||||
"operation.openInNewTab": "在新選項卡中打開",
|
||||
"operation.params": "引數設定",
|
||||
"operation.pause": "暫停",
|
||||
"operation.play": "播放",
|
||||
"operation.refresh": "重新開始",
|
||||
"operation.regenerate": "再生",
|
||||
"operation.reload": "重新整理",
|
||||
@ -519,6 +522,7 @@
|
||||
"operation.rename": "重新命名",
|
||||
"operation.reset": "重置",
|
||||
"operation.resetKeywords": "重置關鍵字",
|
||||
"operation.retry": "重試",
|
||||
"operation.save": "儲存",
|
||||
"operation.saveAndEnable": "儲存並啟用",
|
||||
"operation.saveAndRegenerate": "保存並重新生成子塊",
|
||||
@ -533,13 +537,19 @@
|
||||
"operation.skip": "船",
|
||||
"operation.submit": "提交",
|
||||
"operation.sure": "我確定",
|
||||
"operation.toggleFullscreen": "切換全螢幕",
|
||||
"operation.toggleMute": "切換靜音",
|
||||
"operation.view": "視圖",
|
||||
"operation.viewDetails": "查看詳情",
|
||||
"operation.viewMore": "查看更多",
|
||||
"operation.yes": "是",
|
||||
"operation.zoomIn": "放大",
|
||||
"operation.zoomOut": "縮小",
|
||||
"pagination.editPageNumber": "編輯頁碼,目前第 {{page}} 頁,共 {{totalPages}} 頁",
|
||||
"pagination.next": "下一頁",
|
||||
"pagination.pageNumber": "頁碼",
|
||||
"pagination.perPage": "每頁項目數",
|
||||
"pagination.previous": "上一頁",
|
||||
"placeholder.input": "請輸入",
|
||||
"placeholder.search": "搜尋...",
|
||||
"placeholder.select": "請選擇",
|
||||
@ -677,5 +687,6 @@
|
||||
"voiceInput.converting": "正在轉換為文字...",
|
||||
"voiceInput.notAllow": "麥克風未授權",
|
||||
"voiceInput.speaking": "現在講...",
|
||||
"voiceInput.start": "語音輸入",
|
||||
"you": "你"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user