Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
afa212f
docs: regroup form control stories
lyzno1 May 18, 2026
9abaa60
feat: add dify-ui form primitives
lyzno1 May 18, 2026
7839468
refactor: compose checkbox group stories with dify-ui field
lyzno1 May 18, 2026
f404b3e
refactor: label checkbox list with fieldset
lyzno1 May 18, 2026
c177911
refactor: expose base ui form directly
lyzno1 May 18, 2026
362624a
fix: align field styling with design system
lyzno1 May 18, 2026
933e327
docs: add multi-field form story
lyzno1 May 18, 2026
02245a1
refactor: model api extension modal as form
lyzno1 May 18, 2026
0d0f5d6
docs: add dialog form story
lyzno1 May 18, 2026
261cbff
refactor: tighten api extension modal contract
lyzno1 May 18, 2026
88597c1
Merge branch 'main' into codex/dify-ui-form-system
lyzno1 May 18, 2026
4a27cd9
docs: document dify-ui form contract
lyzno1 May 18, 2026
2a74ab1
fix(web): align checkbox list field semantics
lyzno1 May 18, 2026
cd04f1d
refactor: drive api extension state with orpc
lyzno1 May 18, 2026
e2fa7e2
[autofix.ci] apply automated fixes
autofix-ci[bot] May 18, 2026
8208ac0
Merge remote-tracking branch 'origin/main' into codex/dify-ui-form-sy…
lyzno1 May 18, 2026
521af72
fix: named export
lyzno1 May 18, 2026
d8dd5fe
fix(api): normalize api extension mutation response
lyzno1 May 18, 2026
c68ce69
fix(web): align github installer fields
lyzno1 May 18, 2026
901097b
Merge branch 'main' into codex/dify-ui-form-system
lyzno1 May 19, 2026
a6af695
fix(web): submit github installer form on enter
lyzno1 May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions api/controllers/console/extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ def _serialize_api_based_extension(extension: APIBasedExtension) -> dict[str, An
return APIBasedExtensionResponse.model_validate(extension, from_attributes=True).model_dump(mode="json")


def _serialize_saved_api_based_extension(extension: APIBasedExtension, api_key: str) -> dict[str, Any]:
"""Serialize a saved extension with the plaintext key used for response masking only.

APIBasedExtensionService.save mutates the ORM object to hold the encrypted token before returning it. The response
contract, however, should match list/detail responses, where api_key is masked from the decrypted token.
"""
return APIBasedExtensionResponse(
id=extension.id,
name=extension.name,
api_endpoint=extension.api_endpoint,
api_key=api_key,
created_at=to_timestamp(extension.created_at),
).model_dump(mode="json")


@console_ns.route("/code-based-extension")
class CodeBasedExtensionAPI(Resource):
@console_ns.doc("get_code_based_extension")
Expand Down Expand Up @@ -125,7 +140,7 @@ def post(self):
api_key=payload.api_key,
)

return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data))
return _serialize_saved_api_based_extension(APIBasedExtensionService.save(extension_data), payload.api_key), 201


@console_ns.route("/api-based-extension/<uuid:id>")
Expand Down Expand Up @@ -160,14 +175,19 @@ def post(self, id):
extension_data_from_db = APIBasedExtensionService.get_with_tenant_id(current_tenant_id, api_based_extension_id)

payload = APIBasedExtensionPayload.model_validate(console_ns.payload or {})
api_key_for_response = extension_data_from_db.api_key

extension_data_from_db.name = payload.name
extension_data_from_db.api_endpoint = payload.api_endpoint

if payload.api_key != HIDDEN_VALUE:
extension_data_from_db.api_key = payload.api_key
api_key_for_response = payload.api_key

return _serialize_api_based_extension(APIBasedExtensionService.save(extension_data_from_db))
return _serialize_saved_api_based_extension(
APIBasedExtensionService.save(extension_data_from_db),
api_key_for_response,
)

@console_ns.doc("delete_api_based_extension")
@console_ns.doc(description="Delete API-based extension")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
"""Integration tests for console API-based extension endpoints using testcontainers."""

from __future__ import annotations

from unittest.mock import patch

import pytest
from flask.testing import FlaskClient
from sqlalchemy.orm import Session

from constants import HIDDEN_VALUE
from libs.rsa import generate_key_pair
from models import Tenant
from tests.test_containers_integration_tests.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
)


def _masked_api_key(api_key: str) -> str:
if len(api_key) <= 8:
return api_key[0] + "******" + api_key[-1]
return api_key[:3] + "******" + api_key[-3:]


@pytest.fixture
def api_extension_client(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
) -> tuple[FlaskClient, dict[str, str], Tenant]:
account, tenant = create_console_account_and_tenant(db_session_with_containers)
tenant.encrypt_public_key = generate_key_pair(tenant.id)
db_session_with_containers.commit()

headers = authenticate_console_client(test_client_with_containers, account)
return test_client_with_containers, headers, tenant


@pytest.fixture(autouse=True)
def mock_api_based_extension_ping():
with patch("services.api_based_extension_service.APIBasedExtensionRequestor") as requestor:
requestor.return_value.request.return_value = {"result": "pong"}
yield requestor


def test_create_response_masks_plaintext_api_key(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
api_key = "plain-secret-12345"

response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": api_key,
},
)

assert response.status_code == 201
assert response.json is not None
assert response.json["api_key"] == _masked_api_key(api_key)


def test_update_response_masks_new_plaintext_api_key(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
new_api_key = "new-secret-67890"
create_response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": "old-secret-12345",
},
)
assert create_response.json is not None

update_response = client.post(
f"/console/api/api-based-extension/{create_response.json['id']}",
headers=headers,
json={
"name": "Docs API Updated",
"api_endpoint": "https://docs.example.com/v2",
"api_key": new_api_key,
},
)

assert update_response.status_code == 200
assert update_response.json is not None
assert update_response.json["api_key"] == _masked_api_key(new_api_key)


def test_update_response_masks_existing_plaintext_api_key_when_hidden_value_is_submitted(
api_extension_client: tuple[FlaskClient, dict[str, str], Tenant],
) -> None:
client, headers, _ = api_extension_client
existing_api_key = "old-secret-12345"
create_response = client.post(
"/console/api/api-based-extension",
headers=headers,
json={
"name": "Docs API",
"api_endpoint": "https://docs.example.com/hook",
"api_key": existing_api_key,
},
)
assert create_response.json is not None

update_response = client.post(
f"/console/api/api-based-extension/{create_response.json['id']}",
headers=headers,
json={
"name": "Docs API Updated",
"api_endpoint": "https://docs.example.com/v2",
"api_key": HIDDEN_VALUE,
},
)

assert update_response.status_code == 200
assert update_response.json is not None
assert update_response.json["api_key"] == _masked_api_key(existing_api_key)
14 changes: 12 additions & 2 deletions api/tests/unit_tests/controllers/console/test_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ def _make_extension(
return extension


def _masked_api_key(api_key: str) -> str:
if len(api_key) <= 8:
return api_key[0] + "******" + api_key[-1]
return api_key[:3] + "******" + api_key[-3:]


@pytest.fixture(autouse=True)
def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
"""Bypass console decorators so handlers can run in isolation."""
Expand Down Expand Up @@ -114,7 +120,7 @@ def test_api_based_extension_get_returns_tenant_extensions(app: Flask, monkeypat


def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
saved_extension = _make_extension(name="Docs API", api_key="saved-secret")
saved_extension = _make_extension(name="Docs API", api_key="encrypted-token-from-save")
save_mock = MagicMock(return_value=saved_extension)
monkeypatch.setattr("controllers.console.extension.APIBasedExtensionService.save", save_mock)

Expand All @@ -125,15 +131,17 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt
}

with app.test_request_context("/console/api/api-based-extension", method="POST", json=payload):
response = APIBasedExtensionAPI().post()
response, status = APIBasedExtensionAPI().post()

args, _ = save_mock.call_args
created_extension: APIBasedExtension = args[0]
assert created_extension.tenant_id == "tenant-123"
assert created_extension.name == payload["name"]
assert created_extension.api_endpoint == payload["api_endpoint"]
assert created_extension.api_key == payload["api_key"]
assert status == 201
assert response["name"] == saved_extension.name
assert response["api_key"] == _masked_api_key(payload["api_key"])
save_mock.assert_called_once()


Expand Down Expand Up @@ -183,6 +191,7 @@ def test_api_based_extension_detail_post_keeps_hidden_api_key(app: Flask, monkey
assert existing_extension.api_key == "keep-me"
save_mock.assert_called_once_with(existing_extension)
assert response["name"] == payload["name"]
assert response["api_key"] == _masked_api_key("keep-me")


def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flask, monkeypatch: pytest.MonkeyPatch):
Expand Down Expand Up @@ -212,6 +221,7 @@ def test_api_based_extension_detail_post_updates_api_key_when_provided(app: Flas
assert existing_extension.api_key == "new-secret"
save_mock.assert_called_once_with(existing_extension)
assert response["name"] == payload["name"]
assert response["api_key"] == _masked_api_key(payload["api_key"])


def test_api_based_extension_detail_delete_removes_extension(app: Flask, monkeypatch: pytest.MonkeyPatch):
Expand Down
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -4642,11 +4642,6 @@
"count": 3
}
},
"web/service/client.spec.ts": {
"next/no-assign-module-variable": {
"count": 1
}
},
"web/service/common.ts": {
"ts/no-explicit-any": {
"count": 29
Expand Down
47 changes: 41 additions & 6 deletions packages/dify-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent, DialogTrigger } from '@langgenius/dify-ui/dialog'
import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import '@langgenius/dify-ui/styles.css' // once, in the app root
```
Expand All @@ -37,18 +39,48 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported

## Primitives

| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./autocomplete`, `./combobox`, `./number-field`, `./slider`, `./switch` | Controlled / uncontrolled per Base UI defaults. |
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
| Media | `./avatar`, `./button` | Button exposes `cva` variants. |
| Category | Subpath | Notes |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Overlay | `./alert-dialog`, `./autocomplete`, `./combobox`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./select`, `./toast`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
| Form | `./form`, `./field`, `./fieldset`, `./checkbox`, `./checkbox-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`, `./button` | Button exposes `cva` variants. |

Utilities:

- `./cn` — `clsx` + `tailwind-merge` wrapper. Use this for conditional class composition.
- `./styles.css` — the one CSS entry that ships the design tokens, theme variables, and project utilities/components. Import it once from the app root.

## Form contract

Dify UI's form primitives are a Base UI composition layer for native form semantics, field accessibility, and design-system styling. They are intentionally not a form state-management framework. See the upstream [Base UI Form], [Base UI Field], and [Base UI Fieldset] docs for the underlying component contracts.

Use `Form` for the submit boundary. It renders a native `<form>`, preserves Enter-to-submit and submit-button behavior, and adds Base UI's `onFormSubmit`, `errors`, `actionsRef`, and `validationMode` APIs for structured values and consolidated field validation. Prefer it over a bare `<form>` when the form is composed with Dify UI fields.

Use `FieldRoot` for each named field. A field must have a stable `name`, a visible `FieldLabel`, and either a `FieldControl` or another control that participates in the same Base UI field context. `FieldLabel`, `FieldDescription`, and `FieldError` provide the label and message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.

Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, or multi-thumb sliders. Compose group controls with the Base UI pattern:

```tsx
<FieldRoot name="allowedNetworkProtocols">
<FieldsetRoot render={<CheckboxGroup />}>
<FieldsetLegend>Allowed network protocols</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="https" />
HTTPS
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
```

`FieldsetRoot` provides the group semantics and legend relationship. It does not own the interactive state of the grouped control. Pass `disabled`, `value`, `defaultValue`, and change handlers to the actual group primitive (`CheckboxGroup`, radio group, slider root, etc.) instead of relying on the fieldset wrapper to manage them.

For complex business forms, keep state ownership outside these primitives. TanStack Form, zod, server validation, dialog reset behavior, and schema-driven rendering belong to the feature layer in `web/`; they should pass `name`, `invalid`, `dirty`, `touched`, `value`, `onValueChange`, and errors into these primitives rather than replacing the field semantics.

Migration rule for `web/`: if a UI has a save/submit action, do not leave it as unrelated `Input` and `Button` pieces. Give it a real submit boundary with `Form` or a native `<form>`, attach visible field names through `FieldLabel`, expose helper/error text through `FieldDescription` / `FieldError`, and keep non-submit buttons as `type="button"`.

## Tailwind CSS v4 integration

This package uses Tailwind CSS v4's CSS-first configuration model. Consumers should import Tailwind from their own root stylesheet, then import this package's CSS entry:
Expand Down Expand Up @@ -138,6 +170,9 @@ See `[AGENTS.md](./AGENTS.md)` for:
- Application state (`jotai`, `zustand`), data fetching (`ky`, `@tanstack/react-query`, `@orpc/*`), i18n (`next-i18next` / `react-i18next`), and routing (`next`) all live in `web/`. This package has zero dependencies on them and must stay that way so it can eventually be consumed by other apps or extracted.
- Business components (chat, workflow, dataset views, etc.). Those belong in `web/app/components/...`.

[Base UI Field]: https://base-ui.com/react/components/field
[Base UI Fieldset]: https://base-ui.com/react/components/fieldset
[Base UI Form]: https://base-ui.com/react/components/form
[Base UI Portal]: https://base-ui.com/react/overview/quick-start#portals
[Base UI]: https://base-ui.com/react
[Overlay & portal contract]: #overlay--portal-contract
12 changes: 12 additions & 0 deletions packages/dify-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@
"types": "./src/dropdown-menu/index.tsx",
"import": "./src/dropdown-menu/index.tsx"
},
"./field": {
"types": "./src/field/index.tsx",
"import": "./src/field/index.tsx"
},
"./fieldset": {
"types": "./src/fieldset/index.tsx",
"import": "./src/fieldset/index.tsx"
},
"./form": {
"types": "./src/form/index.tsx",
"import": "./src/form/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"
Expand Down
32 changes: 16 additions & 16 deletions packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Field } from '@base-ui/react/field'
import { Fieldset } from '@base-ui/react/fieldset'
import { useState } from 'react'
import { render } from 'vitest-browser-react'
import { Checkbox } from '../../checkbox'
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { CheckboxGroup } from '../index'

const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
Expand Down Expand Up @@ -43,26 +43,26 @@ describe('CheckboxGroup', () => {
})
})

it('should compose with Base UI Field and Fieldset without losing labels', async () => {
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
const onValueChange = vi.fn()
const screen = await render(
<Field.Root name="features">
<Fieldset.Root render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<Fieldset.Legend>Features</Fieldset.Legend>
<Field.Item>
<Field.Label>
<FieldRoot name="features">
<FieldsetRoot render={<CheckboxGroup value={['search']} onValueChange={onValueChange} />}>
<FieldsetLegend>Features</FieldsetLegend>
<FieldItem>
<FieldLabel>
<Checkbox value="search" />
Search
</Field.Label>
</Field.Item>
<Field.Item>
<Field.Label>
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel>
<Checkbox value="analytics" />
Analytics
</Field.Label>
</Field.Item>
</Fieldset.Root>
</Field.Root>,
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>,
)

const analytics = screen.getByRole('checkbox', { name: 'Analytics' })
Expand Down
Loading
Loading