From 1efd365b62d6edc17436937b308c5c8e3a0bd664 Mon Sep 17 00:00:00 2001 From: chariri Date: Sat, 9 May 2026 17:21:26 +0900 Subject: [PATCH 01/13] fix(swagger): Apply the inline-nested-dicts patch to HTTP Swagger endpoints (#35952) --- api/dev/generate_swagger_markdown_docs.py | 45 ++++-- api/dev/generate_swagger_specs.py | 54 +------ api/libs/external_api.py | 2 + api/libs/flask_restx_compat.py | 149 ++++++++++++++++++ .../unit_tests/controllers/test_swagger.py | 72 +++++++++ 5 files changed, 260 insertions(+), 62 deletions(-) create mode 100644 api/libs/flask_restx_compat.py create mode 100644 api/tests/unit_tests/controllers/test_swagger.py diff --git a/api/dev/generate_swagger_markdown_docs.py b/api/dev/generate_swagger_markdown_docs.py index 0900d08331..e0028c63f6 100644 --- a/api/dev/generate_swagger_markdown_docs.py +++ b/api/dev/generate_swagger_markdown_docs.py @@ -29,18 +29,39 @@ STALE_COMBINED_MARKDOWN_FILENAME = "api-reference.md" def _convert_spec_to_markdown(spec_path: Path, markdown_path: Path) -> None: - subprocess.run( - [ - "npx", - "--yes", - SWAGGER_MARKDOWN_PACKAGE, - "-i", - str(spec_path), - "-o", - str(markdown_path), - ], - check=True, - ) + markdown_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"{markdown_path.stem}-", dir=markdown_path.parent) as temp_dir: + temp_markdown_path = Path(temp_dir) / markdown_path.name + result = subprocess.run( + [ + "npx", + "--yes", + SWAGGER_MARKDOWN_PACKAGE, + "-i", + str(spec_path), + "-o", + str(temp_markdown_path), + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, + result.args, + output=result.stdout, + stderr=result.stderr, + ) + if not temp_markdown_path.exists(): + converter_output = "\n".join(item for item in (result.stdout, result.stderr) if item).strip() + raise RuntimeError(f"swagger-markdown did not write {markdown_path}: {converter_output}") + + converted_markdown = temp_markdown_path.read_text(encoding="utf-8") + if not converted_markdown.strip(): + raise RuntimeError(f"swagger-markdown wrote an empty document for {markdown_path}") + + markdown_path.write_text(converted_markdown, encoding="utf-8") def _demote_markdown_headings(markdown: str, *, levels: int = 1) -> str: diff --git a/api/dev/generate_swagger_specs.py b/api/dev/generate_swagger_specs.py index 9122f3ab24..254310cd2a 100644 --- a/api/dev/generate_swagger_specs.py +++ b/api/dev/generate_swagger_specs.py @@ -20,7 +20,6 @@ from pathlib import Path from typing import Protocol, TypeGuard from flask import Flask -from flask_restx.swagger import Swagger logger = logging.getLogger(__name__) @@ -48,9 +47,6 @@ SPEC_TARGETS: tuple[SpecTarget, ...] = ( SpecTarget(route="/v1/swagger.json", filename="service-swagger.json", namespace="service"), ) -_ORIGINAL_REGISTER_MODEL = Swagger.register_model -_ORIGINAL_REGISTER_FIELD = Swagger.register_field - def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: """Return whether a nested field map is an anonymous inline mapping.""" @@ -152,56 +148,14 @@ def apply_runtime_defaults() -> None: dify_config.SWAGGER_UI_ENABLED = os.environ["SWAGGER_UI_ENABLED"].lower() == "true" -def _patch_swagger_for_inline_nested_dicts() -> None: - """Teach Flask-RESTX Swagger generation to tolerate inline nested field maps. - - Some existing controllers use `fields.Nested({...})` with a raw field mapping - instead of a named `api.model(...)`. Flask-RESTX crashes on those anonymous - dicts during schema registration, so this helper upgrades them into temporary - named models at export time. - """ - - if getattr(Swagger, "_dify_inline_nested_dict_patch", False): - return - - def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: - anonymous_models = getattr(self, "_anonymous_inline_models", None) - if anonymous_models is None: - anonymous_models = {} - self.__dict__["_anonymous_inline_models"] = anonymous_models - - anonymous_name = anonymous_models.get(id(nested_fields)) - if anonymous_name is None: - anonymous_name = _inline_model_name(nested_fields) - anonymous_models[id(nested_fields)] = anonymous_name - if anonymous_name not in self.api.models: - self.api.model(anonymous_name, nested_fields) - - return self.api.models[anonymous_name] - - def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: - if _is_inline_field_map(model): - model = get_or_create_inline_model(self, model) - - return _ORIGINAL_REGISTER_MODEL(self, model) - - def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: - nested = getattr(field, "nested", None) - if _is_inline_field_map(nested): - field.model = get_or_create_inline_model(self, nested) # type: ignore - - _ORIGINAL_REGISTER_FIELD(self, field) - - Swagger.register_model = register_model_with_inline_dict_support - Swagger.register_field = register_field_with_inline_dict_support - Swagger._dify_inline_nested_dict_patch = True - - def create_spec_app() -> Flask: """Build a minimal Flask app that only mounts the Swagger-producing blueprints.""" apply_runtime_defaults() - _patch_swagger_for_inline_nested_dicts() + + from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts + + patch_swagger_for_inline_nested_dicts() app = Flask(__name__) diff --git a/api/libs/external_api.py b/api/libs/external_api.py index f907d17750..64eb99a42b 100644 --- a/api/libs/external_api.py +++ b/api/libs/external_api.py @@ -9,6 +9,7 @@ from werkzeug.http import HTTP_STATUS_CODES from configs import dify_config from core.errors.error import AppInvokeQuotaExceededError +from libs.flask_restx_compat import patch_swagger_for_inline_nested_dicts from libs.token import build_force_logout_cookie_headers @@ -120,6 +121,7 @@ class ExternalApi(Api): } def __init__(self, app: Blueprint | Flask, *args, **kwargs): + patch_swagger_for_inline_nested_dicts() kwargs.setdefault("authorizations", self._authorizations) kwargs.setdefault("security", "Bearer") kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED diff --git a/api/libs/flask_restx_compat.py b/api/libs/flask_restx_compat.py new file mode 100644 index 0000000000..34e0d586a0 --- /dev/null +++ b/api/libs/flask_restx_compat.py @@ -0,0 +1,149 @@ +"""Compatibility helpers for Dify's Flask-RESTX Swagger integration. + +These helpers are temporary bridges for legacy Flask-RESTX field contracts +while controllers migrate their request and response documentation to Pydantic +models. Keep the behavior centralized so live Swagger endpoints and offline +spec export fail or succeed in the same way. +""" + +import hashlib +import json +from typing import TypeGuard + +from flask import current_app +from flask_restx import fields +from flask_restx.model import Model, OrderedModel, instance +from flask_restx.swagger import Swagger + + +def _is_inline_field_map(value: object) -> TypeGuard[dict[object, object]]: + """Return whether a nested field map is an anonymous inline mapping.""" + + return isinstance(value, dict) and not isinstance(value, (Model, OrderedModel)) + + +def _jsonable_schema_value(value: object) -> object: + """Return a deterministic JSON-serializable representation for schema fingerprints.""" + + if value is None or isinstance(value, str | int | float | bool): + return value + if isinstance(value, list | tuple): + return [_jsonable_schema_value(item) for item in value] + if isinstance(value, dict): + return {str(key): _jsonable_schema_value(item) for key, item in value.items()} + value_type = type(value) + return f"<{value_type.__module__}.{value_type.__qualname__}>" + + +def _field_signature(field: object) -> object: + """Build a stable signature for a Flask-RESTX field object.""" + + field_instance = instance(field) + signature: dict[str, object] = { + "class": f"{field_instance.__class__.__module__}.{field_instance.__class__.__qualname__}" + } + + if isinstance(field_instance, fields.Nested): + nested = getattr(field_instance, "nested", None) + if _is_inline_field_map(nested): + signature["nested"] = _inline_model_signature(nested) + else: + signature["nested"] = getattr( + nested, + "name", + f"<{type(nested).__module__}.{type(nested).__qualname__}>", + ) + elif hasattr(field_instance, "container"): + signature["container"] = _field_signature(field_instance.container) + else: + schema = getattr(field_instance, "__schema__", None) + if isinstance(schema, dict): + signature["schema"] = _jsonable_schema_value(schema) + + for attr_name in ( + "attribute", + "default", + "description", + "example", + "max", + "max_items", + "min", + "min_items", + "nullable", + "readonly", + "required", + "title", + "unique", + ): + if hasattr(field_instance, attr_name): + signature[attr_name] = _jsonable_schema_value(getattr(field_instance, attr_name)) + + return signature + + +def _inline_model_signature(nested_fields: dict[object, object]) -> object: + """Build a stable signature for an anonymous inline model.""" + + return [ + (str(field_name), _field_signature(field)) + for field_name, field in sorted(nested_fields.items(), key=lambda item: str(item[0])) + ] + + +def _inline_model_name(nested_fields: dict[object, object]) -> str: + """Return a stable Swagger model name for an anonymous inline field map.""" + + signature = json.dumps(_inline_model_signature(nested_fields), sort_keys=True, separators=(",", ":")) + digest = hashlib.sha1(signature.encode("utf-8")).hexdigest()[:12] + return f"_AnonymousInlineModel_{digest}" + + +def patch_swagger_for_inline_nested_dicts() -> None: + """Allow Swagger generation to handle legacy inline Flask-RESTX field dicts. + + Some existing controllers use raw field mappings in `fields.Nested({...})` + or directly in `@namespace.response(...)`. Runtime marshalling accepts that, + but Flask-RESTX Swagger registration expects a named model. Convert those + anonymous mappings into temporary named models during docs generation. + """ + + if getattr(Swagger, "_dify_inline_nested_dict_patch", False): + return + + original_register_model = Swagger.register_model + original_register_field = Swagger.register_field + original_as_dict = Swagger.as_dict + + def get_or_create_inline_model(self: Swagger, nested_fields: dict[object, object]) -> object: + anonymous_name = _inline_model_name(nested_fields) + if anonymous_name not in self.api.models: + self.api.model(anonymous_name, nested_fields) + + return self.api.models[anonymous_name] + + def register_model_with_inline_dict_support(self: Swagger, model: object) -> dict[str, str]: + if _is_inline_field_map(model): + model = get_or_create_inline_model(self, model) + + return original_register_model(self, model) + + def register_field_with_inline_dict_support(self: Swagger, field: object) -> None: + nested = getattr(field, "nested", None) + if _is_inline_field_map(nested): + field.model = get_or_create_inline_model(self, nested) # type: ignore[attr-defined] + + original_register_field(self, field) + + def as_dict_with_inline_dict_support(self: Swagger): + # Temporary set RESTX_INCLUDE_ALL_MODELS = false to prevent "length changed while iterating" error + include_all_models = current_app.config.get("RESTX_INCLUDE_ALL_MODELS", False) + current_app.config["RESTX_INCLUDE_ALL_MODELS"] = False + try: + return original_as_dict(self) + finally: + current_app.config["RESTX_INCLUDE_ALL_MODELS"] = include_all_models + + Swagger.register_model = register_model_with_inline_dict_support + Swagger.register_field = register_field_with_inline_dict_support + Swagger.as_dict = as_dict_with_inline_dict_support + Swagger._dify_inline_nested_dict_patch = True diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py new file mode 100644 index 0000000000..999f1ae78d --- /dev/null +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -0,0 +1,72 @@ +"""Swagger JSON rendering tests for Flask-RESTX API blueprints.""" + +import pytest +from flask import Flask + + +def _definition_refs(value: object) -> set[str]: + refs: set[str] = set() + if isinstance(value, dict): + ref = value.get("$ref") + if isinstance(ref, str) and ref.startswith("#/definitions/"): + refs.add(ref.removeprefix("#/definitions/")) + for item in value.values(): + refs.update(_definition_refs(item)) + elif isinstance(value, list): + for item in value: + refs.update(_definition_refs(item)) + return refs + + +@pytest.mark.parametrize( + ("first_kwargs", "second_kwargs"), + [ + ({"min_items": 1}, {"min_items": 2}), + ({"max_items": 1}, {"max_items": 2}), + ({"unique": True}, {"unique": False}), + ], +) +def test_inline_model_name_includes_list_constraints( + first_kwargs: dict[str, object], + second_kwargs: dict[str, object], +): + from flask_restx import fields + + from libs.flask_restx_compat import _inline_model_name + + first_inline_model: dict[object, object] = {"items": fields.List(fields.String, **first_kwargs)} + second_inline_model: dict[object, object] = {"items": fields.List(fields.String, **second_kwargs)} + + assert _inline_model_name(first_inline_model) != _inline_model_name(second_inline_model) + + +def test_swagger_json_endpoints_render(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + from controllers.service_api import bp as service_api_bp + from controllers.web import bp as web_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.config["RESTX_INCLUDE_ALL_MODELS"] = True + app.register_blueprint(console_bp) + app.register_blueprint(web_bp) + app.register_blueprint(service_api_bp) + + client = app.test_client() + + for route in ("/console/api/swagger.json", "/api/swagger.json", "/v1/swagger.json"): + response = client.get(route) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["swagger"] == "2.0" + assert "paths" in payload + assert "definitions" in payload + assert isinstance(payload["definitions"], dict) + missing_refs = _definition_refs(payload) - set(payload["definitions"]) + assert not sorted(ref for ref in missing_refs if ref.startswith("_AnonymousInlineModel")) + + assert app.config["RESTX_INCLUDE_ALL_MODELS"] is True From 861f73267ca41027d15c3f27ff06a9f8a5d20f82 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Sat, 9 May 2026 16:23:50 +0800 Subject: [PATCH 02/13] feat(dify-ui): add Tabs/ToggleGroup (#35965) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 13 +- packages/dify-ui/package.json | 8 + .../dify-ui/src/tabs/__tests__/index.spec.tsx | 80 ++++++++ packages/dify-ui/src/tabs/index.stories.tsx | 51 +++++ packages/dify-ui/src/tabs/index.tsx | 59 ++++++ .../src/toggle-group/__tests__/index.spec.tsx | 74 ++++++++ .../src/toggle-group/index.stories.tsx | 177 ++++++++++++++++++ packages/dify-ui/src/toggle-group/index.tsx | 58 ++++++ .../variable-or-constant-input.spec.tsx | 59 ------ .../field/variable-or-constant-input.tsx | 86 --------- .../__tests__/index.spec.tsx | 114 ----------- .../base/segmented-control/index.css | 109 ----------- .../base/segmented-control/index.stories.tsx | 94 ---------- .../base/segmented-control/index.tsx | 151 --------------- .../develop/__tests__/code.spec.tsx | 4 +- web/app/components/develop/code.tsx | 151 +++++++++------ .../__tests__/panel-output-section.spec.tsx | 2 +- .../json-schema-config-modal/index.tsx | 10 +- .../json-schema-config.tsx | 116 +++++++----- .../llm/components/panel-output-section.tsx | 2 +- .../nodes/llm/components/structure-output.tsx | 26 +-- .../components/__tests__/integration.spec.tsx | 11 -- .../__tests__/mode-switcher.spec.tsx | 16 -- .../components/mode-switcher.tsx | 37 ---- .../__tests__/display-content.spec.tsx | 2 +- .../variable-inspect/display-content.tsx | 47 ++--- .../value-content-sections.tsx | 2 +- web/app/styles/tailwind-core.css | 1 - 28 files changed, 718 insertions(+), 842 deletions(-) create mode 100644 packages/dify-ui/src/tabs/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/tabs/index.stories.tsx create mode 100644 packages/dify-ui/src/tabs/index.tsx create mode 100644 packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/toggle-group/index.stories.tsx create mode 100644 packages/dify-ui/src/toggle-group/index.tsx delete mode 100644 web/app/components/base/form/components/field/__tests__/variable-or-constant-input.spec.tsx delete mode 100644 web/app/components/base/form/components/field/variable-or-constant-input.tsx delete mode 100644 web/app/components/base/segmented-control/__tests__/index.spec.tsx delete mode 100644 web/app/components/base/segmented-control/index.css delete mode 100644 web/app/components/base/segmented-control/index.stories.tsx delete mode 100644 web/app/components/base/segmented-control/index.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/__tests__/mode-switcher.spec.tsx delete mode 100644 web/app/components/workflow/nodes/trigger-schedule/components/mode-switcher.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2326e92d2f..683e6b09fe 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1051,14 +1051,6 @@ "count": 1 } }, - "web/app/components/base/form/components/field/variable-or-constant-input.tsx": { - "no-console": { - "count": 2 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/base/form/components/field/variable-selector.tsx": { "no-console": { "count": 1 @@ -2307,11 +2299,8 @@ } }, "web/app/components/develop/code.tsx": { - "ts/no-empty-object-type": { - "count": 1 - }, "ts/no-explicit-any": { - "count": 9 + "count": 7 } }, "web/app/components/develop/md.tsx": { diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 894e92bfd6..96c512f89c 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -77,6 +77,14 @@ "types": "./src/switch/index.tsx", "import": "./src/switch/index.tsx" }, + "./tabs": { + "types": "./src/tabs/index.tsx", + "import": "./src/tabs/index.tsx" + }, + "./toggle-group": { + "types": "./src/toggle-group/index.tsx", + "import": "./src/toggle-group/index.tsx" + }, "./toast": { "types": "./src/toast/index.tsx", "import": "./src/toast/index.tsx" diff --git a/packages/dify-ui/src/tabs/__tests__/index.spec.tsx b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx new file mode 100644 index 0000000000..6673e35bf5 --- /dev/null +++ b/packages/dify-ui/src/tabs/__tests__/index.spec.tsx @@ -0,0 +1,80 @@ +import { render } from 'vitest-browser-react' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Tabs wrappers', () => { + it('renders Base UI tabs with accessible roles', async () => { + const screen = await render( + + + JavaScript + Python + + JS panel + Python panel + , + ) + + await expect.element(screen.getByRole('tablist')).toBeInTheDocument() + await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + await expect.element(screen.getByRole('tab', { name: 'Python' })).toHaveAttribute('aria-selected', 'false') + await expect.element(screen.getByText('JS panel')).toBeInTheDocument() + }) + + it('keeps tabs styling minimal by default', async () => { + const screen = await render( + + + First + Second + + , + ) + + await expect.element(screen.getByRole('tablist')).toHaveClass( + 'flex', + ) + await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass( + 'touch-manipulation', + 'focus-visible:outline-hidden', + ) + }) + + it('calls onValueChange while leaving controlled value to the caller', async () => { + const onValueChange = vi.fn() + const screen = await render( + + + JavaScript + Python + + , + ) + + asHTMLElement(screen.getByRole('tab', { name: 'Python' }).element()).click() + + expect(onValueChange).toHaveBeenCalledWith('py', expect.anything()) + await expect.element(screen.getByRole('tab', { name: 'JavaScript' })).toHaveAttribute('aria-selected', 'true') + }) + + it('forwards className to composable parts', async () => { + const screen = await render( + + + First + + Panel + , + ) + + await expect.element(screen.getByRole('tablist')).toHaveClass('custom-list') + await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass('custom-tab') + expect(screen.getByText('Panel').element()).toHaveClass('custom-panel') + }) +}) diff --git a/packages/dify-ui/src/tabs/index.stories.tsx b/packages/dify-ui/src/tabs/index.stories.tsx new file mode 100644 index 0000000000..dd1e79a1ce --- /dev/null +++ b/packages/dify-ui/src/tabs/index.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + Tabs, + TabsList, + TabsPanel, + TabsTab, +} from '.' + +const meta = { + title: 'Base/UI/Tabs', + component: Tabs, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Composable tabs built on Base UI. Use this when a tab controls a corresponding tab panel.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Basic: Story = { + render: () => ( + + + + Overview + + + Activity + + + + Overview panel + + + Activity panel + + + ), +} diff --git a/packages/dify-ui/src/tabs/index.tsx b/packages/dify-ui/src/tabs/index.tsx new file mode 100644 index 0000000000..ddc5891b89 --- /dev/null +++ b/packages/dify-ui/src/tabs/index.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { Tabs as BaseTabsNS } from '@base-ui/react/tabs' +import { Tabs as BaseTabs } from '@base-ui/react/tabs' +import { cn } from '../cn' + +export type TabsProps = BaseTabsNS.Root.Props + +export const Tabs = BaseTabs.Root + +export type TabsListProps = Omit & { + className?: string +} + +export function TabsList({ + className, + ...props +}: TabsListProps) { + return ( + + ) +} + +export type TabsTabProps = Omit & { + className?: string +} + +export function TabsTab({ + className, + ...props +}: TabsTabProps) { + return ( + + ) +} + +export type TabsPanelProps = Omit & { + className?: string +} + +export function TabsPanel({ + className, + ...props +}: TabsPanelProps) { + return ( + + ) +} + +export const TabsIndicator = BaseTabs.Indicator diff --git a/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx b/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx new file mode 100644 index 0000000000..ec6e7351e2 --- /dev/null +++ b/packages/dify-ui/src/toggle-group/__tests__/index.spec.tsx @@ -0,0 +1,74 @@ +import { render } from 'vitest-browser-react' +import { + ToggleGroup, + ToggleGroupDivider, + ToggleGroupItem, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('ToggleGroup wrappers', () => { + it('renders a segmented control with Base UI pressed state', async () => { + const screen = await render( + + One + Two + , + ) + + await expect.element(screen.getByRole('group')).toHaveClass( + 'bg-components-segmented-control-bg-normal', + 'p-0.5', + 'rounded-[10px]', + ) + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass( + 'data-pressed:bg-components-segmented-control-item-active-bg', + 'data-pressed:text-text-accent-light-mode-only', + ) + }) + + it('uses single selection by default', async () => { + const screen = await render( + + One + Two + , + ) + + asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() + + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'false') + await expect.element(screen.getByRole('button', { name: 'Two' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('calls onValueChange while leaving controlled value to the caller', async () => { + const onValueChange = vi.fn() + const screen = await render( + + One + Two + , + ) + + asHTMLElement(screen.getByRole('button', { name: 'Two' }).element()).click() + + expect(onValueChange).toHaveBeenCalledWith(['two'], expect.anything()) + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveAttribute('aria-pressed', 'true') + }) + + it('forwards disabled and className to composable parts', async () => { + const screen = await render( + + One + + Two + , + ) + + await expect.element(screen.getByRole('group')).toHaveClass('custom-group') + await expect.element(screen.getByRole('button', { name: 'One' })).toHaveClass('custom-item') + await expect.element(screen.getByRole('button', { name: 'Two' })).toBeDisabled() + await expect.element(screen.getByTestId('divider')).toHaveClass('custom-divider') + }) +}) diff --git a/packages/dify-ui/src/toggle-group/index.stories.tsx b/packages/dify-ui/src/toggle-group/index.stories.tsx new file mode 100644 index 0000000000..960957b7ab --- /dev/null +++ b/packages/dify-ui/src/toggle-group/index.stories.tsx @@ -0,0 +1,177 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import type { ReactNode } from 'react' +import { + ToggleGroup, + ToggleGroupDivider, + ToggleGroupItem, +} from '.' + +const meta = { + title: 'Base/UI/ToggleGroup', + component: ToggleGroup, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Segmented control built on Base UI ToggleGroup and Toggle. Use this for mode, filter, and view selection that does not need tabpanel semantics.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +type SegmentedControlProps = { + defaultValue: string + values: string[] + iconOnly?: boolean + noPadding?: boolean +} + +const Icon = () => ( +