From 04d62867af40ebe30e3fefb74fab5245452ca47c Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 19 May 2026 13:38:57 +0800 Subject: [PATCH] feat(dify-ui): add shared form primitives (#36334) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/extension.py | 24 +- .../console/test_api_based_extension.py | 126 ++++++++++ .../controllers/console/test_extension.py | 14 +- eslint-suppressions.json | 5 - packages/dify-ui/README.md | 47 +++- packages/dify-ui/package.json | 12 + .../checkbox-group/__tests__/index.spec.tsx | 32 +-- .../src/checkbox-group/index.stories.tsx | 35 +-- .../dify-ui/src/checkbox/index.stories.tsx | 2 +- packages/dify-ui/src/dialog/index.stories.tsx | 85 +++++++ .../src/field/__tests__/index.spec.tsx | 126 ++++++++++ packages/dify-ui/src/field/index.stories.tsx | 111 +++++++++ packages/dify-ui/src/field/index.tsx | 154 ++++++++++++ .../src/fieldset/__tests__/index.spec.tsx | 21 ++ .../dify-ui/src/fieldset/index.stories.tsx | 56 +++++ packages/dify-ui/src/fieldset/index.tsx | 41 ++++ .../dify-ui/src/form/__tests__/index.spec.tsx | 53 +++++ packages/dify-ui/src/form/index.stories.tsx | 70 ++++++ packages/dify-ui/src/form/index.tsx | 11 + .../src/number-field/index.stories.tsx | 2 +- packages/dify-ui/src/select/index.stories.tsx | 2 +- packages/dify-ui/src/slider/index.stories.tsx | 2 +- packages/dify-ui/src/switch/index.stories.tsx | 2 +- packages/dify-ui/vite.config.ts | 3 + .../external-data-tool-modal.spec.tsx | 2 +- .../tools/external-data-tool-modal.tsx | 2 +- .../checkbox-list/__tests__/index.spec.tsx | 2 + .../components/base/checkbox-list/index.tsx | 224 ++++++++++-------- .../moderation-setting-modal.spec.tsx | 2 +- .../moderation/moderation-setting-modal.tsx | 2 +- .../base/form/components/base/base-field.tsx | 1 + .../account-setting/__tests__/index.spec.tsx | 24 +- .../__tests__/empty.spec.tsx | 2 +- .../__tests__/index.spec.tsx | 115 ++++++--- .../__tests__/item.spec.tsx | 63 +++-- .../__tests__/modal.spec.tsx | 145 +++++++----- .../__tests__/selector.spec.tsx | 62 +++-- .../api-based-extension-page/empty.tsx | 4 +- .../api-based-extension-page/index.tsx | 59 +++-- .../api-based-extension-page/item.tsx | 46 ++-- .../api-based-extension-page/modal.tsx | 188 +++++++++------ .../api-based-extension-page/selector.tsx | 26 +- .../header/account-setting/index.tsx | 2 +- .../__tests__/index.spec.tsx | 14 ++ .../install-from-github/index.tsx | 41 ++-- .../steps/selectPackage.tsx | 143 ++++++----- .../_base/components/form-input-item.tsx | 1 + web/service/client.spec.ts | 101 +++++++- web/service/client.ts | 40 ++++ web/service/common.ts | 25 -- web/service/use-common.ts | 9 - 51 files changed, 1824 insertions(+), 557 deletions(-) create mode 100644 api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py create mode 100644 packages/dify-ui/src/field/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/field/index.stories.tsx create mode 100644 packages/dify-ui/src/field/index.tsx create mode 100644 packages/dify-ui/src/fieldset/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/fieldset/index.stories.tsx create mode 100644 packages/dify-ui/src/fieldset/index.tsx create mode 100644 packages/dify-ui/src/form/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/form/index.stories.tsx create mode 100644 packages/dify-ui/src/form/index.tsx diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 0c9a93c1cd..e53bb95c24 100644 --- a/api/controllers/console/extension.py +++ b/api/controllers/console/extension.py @@ -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") @@ -125,7 +140,7 @@ class APIBasedExtensionAPI(Resource): 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/") @@ -160,14 +175,19 @@ class APIBasedExtensionDetailAPI(Resource): 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") diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py b/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py new file mode 100644 index 0000000000..e7852b8fe1 --- /dev/null +++ b/api/tests/test_containers_integration_tests/controllers/console/test_api_based_extension.py @@ -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) diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 0d1fb39348..60a7ea5bb5 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -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.""" @@ -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) @@ -125,7 +131,7 @@ 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] @@ -133,7 +139,9 @@ def test_api_based_extension_post_creates_extension(app: Flask, monkeypatch: pyt 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() @@ -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): @@ -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): diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 07504d2754..800bbc746b 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 010fb3e56d..8592622e6f 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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 ``` @@ -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 `
`, 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 `` 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 + + }> + Allowed network protocols + + + + HTTPS + + + + +``` + +`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 ``, 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: @@ -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 diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index ee20896570..d58fb954e0 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -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" diff --git a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx index b5d8e65c99..4e98f42861 100644 --- a/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/checkbox-group/__tests__/index.spec.tsx @@ -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 @@ -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( - - }> - Features - - + + }> + Features + + Search - - - - + + + + Analytics - - - - , + + + + , ) const analytics = screen.getByRole('checkbox', { name: 'Analytics' }) diff --git a/packages/dify-ui/src/checkbox-group/index.stories.tsx b/packages/dify-ui/src/checkbox-group/index.stories.tsx index 623ae62c98..ea4e638bab 100644 --- a/packages/dify-ui/src/checkbox-group/index.stories.tsx +++ b/packages/dify-ui/src/checkbox-group/index.stories.tsx @@ -1,12 +1,17 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import { Field } from '@base-ui/react/field' -import { Fieldset } from '@base-ui/react/fieldset' import { useId, useState } from 'react' import { CheckboxGroup } from '.' import { Checkbox } from '../checkbox' +import { + FieldDescription, + FieldItem, + FieldLabel, + FieldRoot, +} from '../field' +import { FieldsetLegend, FieldsetRoot } from '../fieldset' const meta = { - title: 'Base/UI/CheckboxGroup', + title: 'Base/Form/CheckboxGroup', component: CheckboxGroup, parameters: { layout: 'centered', @@ -75,11 +80,11 @@ function DynamicFormFieldDemo() { const [selected, setSelected] = useState(['markdown']) return ( - - + + This mirrors Dify dynamic form fields where checkbox options are controlled by schema and persisted as a string array. - - + )} > - + Allowed file types - + {options.map(option => ( - - + + {option.label} - - + + ))} - - + + ) } diff --git a/packages/dify-ui/src/checkbox/index.stories.tsx b/packages/dify-ui/src/checkbox/index.stories.tsx index 97cd1a1d60..adf4f28388 100644 --- a/packages/dify-ui/src/checkbox/index.stories.tsx +++ b/packages/dify-ui/src/checkbox/index.stories.tsx @@ -7,7 +7,7 @@ import { } from '.' const meta = { - title: 'Base/UI/Checkbox', + title: 'Base/Form/Checkbox', component: Checkbox, parameters: { layout: 'centered', diff --git a/packages/dify-ui/src/dialog/index.stories.tsx b/packages/dify-ui/src/dialog/index.stories.tsx index f9caa0d8c5..24556a11e7 100644 --- a/packages/dify-ui/src/dialog/index.stories.tsx +++ b/packages/dify-ui/src/dialog/index.stories.tsx @@ -9,6 +9,8 @@ import { DialogTrigger, } from '.' import { Button } from '../button' +import { FieldControl, FieldDescription, FieldError, FieldLabel, FieldRoot } from '../field' +import { Form } from '../form' const triggerButtonClassName = 'rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs hover:bg-state-base-hover' @@ -139,6 +141,89 @@ export const Controlled: Story = { render: () => , } +type ApiExtensionFormValues = { + name: string + endpoint: string + apiKey: string +} + +const FormDialogDemo = () => { + const [open, setOpen] = useState(false) + + return ( + + } + > + Configure API extension + + + +
+ + Configure API extension + + + Save the endpoint and credentials used by this workspace integration. + +
+ + className="grid gap-4 pt-5" + onFormSubmit={() => setOpen(false)} + > + + Name + + Name is required. + + + Endpoint + + + + View API extension docs + + + Endpoint is required. + Enter a valid URL. + + { + if (typeof value === 'string' && value.length > 0 && value.length < 5) + return 'API key must be at least 5 characters.' + + return null + }} + > + API key + + API key is required. + + +
+ + +
+ +
+
+ ) +} + +export const FormDialog: Story = { + render: () => , +} + export const ScrollingContent: Story = { render: () => ( diff --git a/packages/dify-ui/src/field/__tests__/index.spec.tsx b/packages/dify-ui/src/field/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9569212360 --- /dev/null +++ b/packages/dify-ui/src/field/__tests__/index.spec.tsx @@ -0,0 +1,126 @@ +import { render } from 'vitest-browser-react' +import { Checkbox } from '../../checkbox' +import { CheckboxGroup } from '../../checkbox-group' +import { FieldsetLegend, FieldsetRoot } from '../../fieldset' +import { Form } from '../../form' +import { + FieldControl, + FieldDescription, + FieldError, + FieldItem, + FieldLabel, + FieldRoot, +} from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Field primitives', () => { + it('should associate label, description, and error with the control', async () => { + const onFormSubmit = vi.fn() + const screen = await render( +
+ + Email + + Used for account notifications. + Email is required. + + +
, + ) + + const input = screen.getByRole('textbox', { name: 'Email' }) + const label = asHTMLElement(screen.getByText('Email').element()) + const description = asHTMLElement(screen.getByText('Used for account notifications.').element()) + + await expect.element(input).toHaveAccessibleDescription('Used for account notifications.') + expect(label.tagName).toBe('LABEL') + expect(label).toHaveAttribute('for', asHTMLElement(input.element()).id) + expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toContain(description.id) + await expect.element(input).toHaveClass('rounded-lg', 'system-sm-regular') + await expect.element(screen.getByText('Email')).toHaveClass('py-1', 'system-sm-medium') + await expect.element(screen.getByText('Used for account notifications.')).toHaveClass('py-0.5', 'body-xs-regular') + + asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click() + + await vi.waitFor(async () => { + const error = asHTMLElement(screen.getByText('Email is required.').element()) + await expect.element(screen.getByText('Email is required.')).toBeInTheDocument() + await expect.element(input).toHaveAttribute('aria-invalid', 'true') + await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive') + expect(asHTMLElement(input.element()).getAttribute('aria-describedby')?.split(' ')).toEqual( + expect.arrayContaining([description.id, error.id]), + ) + }) + expect(onFormSubmit).not.toHaveBeenCalled() + }) + + it('should submit valid field values through Base UI Form', async () => { + const onFormSubmit = vi.fn() + const screen = await render( +
+ + API key + + + +
, + ) + + asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click() + + expect(onFormSubmit).toHaveBeenCalledTimes(1) + expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ apiKey: 'sk-test' }) + }) + + it('should support external invalid state without requiring FieldControl', async () => { + const screen = await render( + + }> + Features + + + + Search + + + Choose at least one feature. + + , + ) + + await expect.element(screen.getByRole('group', { name: 'Features' })).toBeInTheDocument() + await expect.element(screen.getByRole('checkbox', { name: 'Search' })).toHaveAttribute('aria-checked', 'true') + await expect.element(screen.getByText('Choose at least one feature.')).toHaveClass('text-text-destructive', 'body-xs-regular') + }) + + it('should apply design-system control sizes when requested', async () => { + const screen = await render( + <> + + Name + + + + Alias + + + , + ) + + await expect.element(screen.getByRole('textbox', { name: 'Name' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular') + await expect.element(screen.getByRole('textbox', { name: 'Alias' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular') + }) + + it('should expose the design-system read-only state', async () => { + const screen = await render( + + Token + + , + ) + + await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('readonly') + await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveClass('read-only:cursor-default', 'read-only:focus:border-transparent') + }) +}) diff --git a/packages/dify-ui/src/field/index.stories.tsx b/packages/dify-ui/src/field/index.stories.tsx new file mode 100644 index 0000000000..84b6ac977d --- /dev/null +++ b/packages/dify-ui/src/field/index.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Button } from '../button' +import { + FieldControl, + FieldDescription, + FieldError, + FieldLabel, + FieldRoot, +} from './index' + +const meta = { + title: 'Base/Form/Field', + component: FieldRoot, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Field primitives built on Base UI Field. Use FieldRoot with FieldLabel, FieldControl, FieldDescription, and FieldError for one named form field. External form libraries can control invalid, dirty, and touched on FieldRoot.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const TextField: Story = { + render: () => ( +
+ + Endpoint + + Used as the base URL for extension requests. + Endpoint is required. + Enter a valid URL. + +
+ +
+
+ ), +} + +export const MultipleFields: Story = { + render: () => ( +
+ + Name + + Name is required. + + + Endpoint + + Used as the base URL for extension requests. + Endpoint is required. + Enter a valid URL. + + + API key + + Stored with the extension configuration. + API key is required. + +
+ +
+
+ ), +} + +export const ExternalInvalidState: Story = { + render: () => ( + + API key + + API key has expired. + + ), +} + +export const Sizes: Story = { + render: () => ( +
+ + Small + + + + Regular + + + + Large + + +
+ ), +} + +export const ReadOnly: Story = { + render: () => ( + + Endpoint + + This value is managed by the workspace owner. + + ), +} diff --git a/packages/dify-ui/src/field/index.tsx b/packages/dify-ui/src/field/index.tsx new file mode 100644 index 0000000000..12f0ba6ce8 --- /dev/null +++ b/packages/dify-ui/src/field/index.tsx @@ -0,0 +1,154 @@ +'use client' + +import type { Field as BaseFieldNS } from '@base-ui/react/field' +import type { VariantProps } from 'class-variance-authority' +import { Field as BaseField } from '@base-ui/react/field' +import { cva } from 'class-variance-authority' +import { cn } from '../cn' + +export type FieldRootProps + = Omit + & { + className?: string + } + +export type FieldRootActions = BaseFieldNS.Root.Actions + +export function FieldRoot({ + className, + ...props +}: FieldRootProps) { + return ( + + ) +} + +export type FieldItemProps + = Omit + & { + className?: string + } + +export function FieldItem({ + className, + ...props +}: FieldItemProps) { + return ( + + ) +} + +export type FieldLabelProps + = Omit + & { + className?: string + } + +export function FieldLabel({ + className, + ...props +}: FieldLabelProps) { + return ( + + ) +} + +const fieldControlVariants = cva( + [ + 'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]', + 'placeholder:text-components-input-text-placeholder', + 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover', + 'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs', + 'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive', + 'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none', + 'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled', + 'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled', + 'motion-reduce:transition-none', + ], + { + variants: { + size: { + small: 'rounded-md px-2 py-[3px] system-xs-regular', + medium: 'rounded-lg px-3 py-[7px] system-sm-regular', + large: 'rounded-[10px] px-4 py-[7px] system-md-regular', + }, + }, + defaultVariants: { + size: 'medium', + }, + }, +) + +export type FieldControlSize = NonNullable['size']> + +export type FieldControlProps + = Omit + & VariantProps + & { + className?: string + } + +export type FieldControlChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails + +export function FieldControl({ + className, + size = 'medium', + ...props +}: FieldControlProps) { + return ( + + ) +} + +export type FieldDescriptionProps + = Omit + & { + className?: string + } + +export function FieldDescription({ + className, + ...props +}: FieldDescriptionProps) { + return ( + + ) +} + +export type FieldErrorProps + = Omit + & { + className?: string + } + +export function FieldError({ + className, + ...props +}: FieldErrorProps) { + return ( + + ) +} + +export type FieldValidityProps = BaseFieldNS.Validity.Props +export type FieldValidityState = BaseFieldNS.Validity.State + +export const FieldValidity = BaseField.Validity diff --git a/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx b/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx new file mode 100644 index 0000000000..9ee90af306 --- /dev/null +++ b/packages/dify-ui/src/fieldset/__tests__/index.spec.tsx @@ -0,0 +1,21 @@ +import { render } from 'vitest-browser-react' +import { + FieldsetLegend, + FieldsetRoot, +} from '../index' + +describe('Fieldset primitives', () => { + it('should apply reset design-system classes', async () => { + const screen = await render( + + Permissions + , + ) + + const legend = screen.getByText('Permissions').element() as HTMLElement + const fieldset = legend.closest('fieldset') as HTMLElement + + await expect.element(fieldset).toHaveClass('m-0', 'min-w-0', 'border-0', 'p-0', 'custom-root') + await expect.element(legend).toHaveClass('mb-1', 'py-1', 'system-sm-medium', 'text-text-secondary', 'custom-legend') + }) +}) diff --git a/packages/dify-ui/src/fieldset/index.stories.tsx b/packages/dify-ui/src/fieldset/index.stories.tsx new file mode 100644 index 0000000000..51410ed24c --- /dev/null +++ b/packages/dify-ui/src/fieldset/index.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Checkbox } from '../checkbox' +import { CheckboxGroup } from '../checkbox-group' +import { FieldItem, FieldLabel, FieldRoot } from '../field' +import { + FieldsetLegend, + FieldsetRoot, +} from './index' + +const meta = { + title: 'Base/Form/Fieldset', + component: FieldsetRoot, + parameters: { + layout: 'centered', + docs: { + description: { + component: 'Fieldset primitives built on Base UI Fieldset. 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. Fieldset provides group semantics and labeling; pass interactive state such as disabled and value to the actual group primitive.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const CheckboxGroupField: Story = { + render: () => ( + + }> + Scopes +
+ + + + Read + + + + + + Write + + + + + + Admin + + +
+
+
+ ), +} diff --git a/packages/dify-ui/src/fieldset/index.tsx b/packages/dify-ui/src/fieldset/index.tsx new file mode 100644 index 0000000000..e51804509b --- /dev/null +++ b/packages/dify-ui/src/fieldset/index.tsx @@ -0,0 +1,41 @@ +'use client' + +import type { Fieldset as BaseFieldsetNS } from '@base-ui/react/fieldset' +import { Fieldset as BaseFieldset } from '@base-ui/react/fieldset' +import { cn } from '../cn' + +export type FieldsetRootProps + = Omit + & { + className?: string + } + +export function FieldsetRoot({ + className, + ...props +}: FieldsetRootProps) { + return ( + + ) +} + +export type FieldsetLegendProps + = Omit + & { + className?: string + } + +export function FieldsetLegend({ + className, + ...props +}: FieldsetLegendProps) { + return ( + + ) +} diff --git a/packages/dify-ui/src/form/__tests__/index.spec.tsx b/packages/dify-ui/src/form/__tests__/index.spec.tsx new file mode 100644 index 0000000000..6ce3f6b7e2 --- /dev/null +++ b/packages/dify-ui/src/form/__tests__/index.spec.tsx @@ -0,0 +1,53 @@ +import { render } from 'vitest-browser-react' +import { FieldControl, FieldLabel, FieldRoot } from '../../field' +import { Form } from '../index' + +const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement + +describe('Form primitive', () => { + it('should render a native named form and merge custom class names', async () => { + const screen = await render( +
+ + Name + + +
, + ) + + await expect.element(screen.getByRole('form', { name: 'profile form' })).toHaveClass('custom-form') + }) + + it('should call onFormSubmit with submitted values', async () => { + const onFormSubmit = vi.fn() + const screen = await render( +
+ + Endpoint + + + +
, + ) + + asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click() + + expect(onFormSubmit).toHaveBeenCalledTimes(1) + expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ + endpoint: 'https://api.example.com', + }) + }) + + it('should expose externally supplied errors through FieldError consumers', async () => { + const screen = await render( +
+ + Token + + +
, + ) + + await expect.element(screen.getByRole('textbox', { name: 'Token' })).toHaveAttribute('aria-invalid', 'true') + }) +}) diff --git a/packages/dify-ui/src/form/index.stories.tsx b/packages/dify-ui/src/form/index.stories.tsx new file mode 100644 index 0000000000..f1edac5e7c --- /dev/null +++ b/packages/dify-ui/src/form/index.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Button } from '../button' +import { Checkbox } from '../checkbox' +import { CheckboxGroup } from '../checkbox-group' +import { + FieldControl, + FieldDescription, + FieldError, + FieldItem, + FieldLabel, + FieldRoot, +} from '../field' +import { FieldsetLegend, FieldsetRoot } from '../fieldset' +import { Form } from './index' + +const meta = { + title: 'Base/Form/Form', + component: Form, + parameters: { + layout: 'centered', + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Basic: Story = { + render: () => ( +
undefined}> + + Name + + Name is required. + + + + Email + + Used for account notifications. + Email is required. + Enter a valid email address. + + + + }> + Features +
+ + + + Search + + + + + + Analytics + + +
+
+
+ +
+ +
+
+ ), +} diff --git a/packages/dify-ui/src/form/index.tsx b/packages/dify-ui/src/form/index.tsx new file mode 100644 index 0000000000..09cdc2a623 --- /dev/null +++ b/packages/dify-ui/src/form/index.tsx @@ -0,0 +1,11 @@ +'use client' + +import type { Form as BaseFormNS } from '@base-ui/react/form' +import { Form as BaseForm } from '@base-ui/react/form' + +export const Form = BaseForm + +export type FormProps = BaseFormNS.Props +export type FormActions = BaseFormNS.Actions +export type FormValidationMode = BaseFormNS.ValidationMode +export type FormSubmitEventDetails = BaseFormNS.SubmitEventDetails diff --git a/packages/dify-ui/src/number-field/index.stories.tsx b/packages/dify-ui/src/number-field/index.stories.tsx index a436d997e6..b9472943e0 100644 --- a/packages/dify-ui/src/number-field/index.stories.tsx +++ b/packages/dify-ui/src/number-field/index.stories.tsx @@ -108,7 +108,7 @@ const DemoField = ({ } const meta = { - title: 'Base/UI/NumberField', + title: 'Base/Form/NumberField', component: NumberField, parameters: { layout: 'centered', diff --git a/packages/dify-ui/src/select/index.stories.tsx b/packages/dify-ui/src/select/index.stories.tsx index 6dc832a291..697266dcec 100644 --- a/packages/dify-ui/src/select/index.stories.tsx +++ b/packages/dify-ui/src/select/index.stories.tsx @@ -16,7 +16,7 @@ import { const triggerWidth = 'w-64' const meta = { - title: 'Base/UI/Select', + title: 'Base/Form/Select', component: Select, parameters: { layout: 'centered', diff --git a/packages/dify-ui/src/slider/index.stories.tsx b/packages/dify-ui/src/slider/index.stories.tsx index a48e202142..844a984406 100644 --- a/packages/dify-ui/src/slider/index.stories.tsx +++ b/packages/dify-ui/src/slider/index.stories.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { Slider } from '.' const meta = { - title: 'Base/UI/Slider', + title: 'Base/Form/Slider', component: Slider, parameters: { layout: 'centered', diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index f43b9ae154..4d47ef688e 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -4,7 +4,7 @@ import { useState, useTransition } from 'react' import { Switch, SwitchSkeleton } from '.' const meta = { - title: 'Base/UI/Switch', + title: 'Base/Form/Switch', component: Switch, parameters: { layout: 'centered', diff --git a/packages/dify-ui/vite.config.ts b/packages/dify-ui/vite.config.ts index f2a2d24e57..6a4c2f4286 100644 --- a/packages/dify-ui/vite.config.ts +++ b/packages/dify-ui/vite.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ resolve: { tsconfigPaths: true, }, + optimizeDeps: { + include: ['@base-ui/react/form'], + }, test: { globals: true, setupFiles: ['./vitest.setup.ts'], diff --git a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx index 2a725d88ca..fd87ab0aeb 100644 --- a/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx +++ b/web/app/components/app/configuration/tools/__tests__/external-data-tool-modal.spec.tsx @@ -84,7 +84,7 @@ vi.mock('@/app/components/base/features/new-feature-panel/moderation/form-genera })) vi.mock('@/app/components/header/account-setting/api-based-extension-page/selector', () => ({ - default: ({ + ApiBasedExtensionSelector: ({ onChange, value, }: { diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index b09b7b1c70..16e3633e3e 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -13,7 +13,7 @@ import AppIcon from '@/app/components/base/app-icon' import EmojiPicker from '@/app/components/base/emoji-picker' import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation' import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' -import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' +import { ApiBasedExtensionSelector } from '@/app/components/header/account-setting/api-based-extension-page/selector' import { useDocLink, useLocale } from '@/context/i18n' import { useCodeBasedExtensions } from '@/service/use-common' import { diff --git a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx index 7d536a766a..feccbd0b38 100644 --- a/web/app/components/base/checkbox-list/__tests__/index.spec.tsx +++ b/web/app/components/base/checkbox-list/__tests__/index.spec.tsx @@ -21,6 +21,7 @@ describe('checkbox list component', () => { ) expect(screen.getByText('Test Title'))!.toBeInTheDocument() expect(screen.getByText('Test Description'))!.toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Test Title' }))!.toHaveAccessibleDescription('Test Description') options.forEach((option) => { expect(screen.getByText(option.label))!.toBeInTheDocument() }) @@ -231,6 +232,7 @@ describe('checkbox list component', () => { />, ) expect(screen.getByText('Test Label'))!.toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Test Label' }))!.toBeInTheDocument() }) it('renders without showSelectAll, showCount, showSearch', () => { diff --git a/web/app/components/base/checkbox-list/index.tsx b/web/app/components/base/checkbox-list/index.tsx index 22cd1a5718..0b809e3792 100644 --- a/web/app/components/base/checkbox-list/index.tsx +++ b/web/app/components/base/checkbox-list/index.tsx @@ -3,7 +3,9 @@ import { Button } from '@langgenius/dify-ui/button' import { Checkbox } from '@langgenius/dify-ui/checkbox' import { CheckboxGroup } from '@langgenius/dify-ui/checkbox-group' import { cn } from '@langgenius/dify-ui/cn' -import { useId, useMemo, useState } from 'react' +import { FieldDescription, FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' +import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import SearchInput from '@/app/components/base/search-input' @@ -16,6 +18,7 @@ type CheckboxListOption = { } type CheckboxListProps = { + name?: string title?: string label?: string description?: string @@ -31,6 +34,7 @@ type CheckboxListProps = { } export const CheckboxList = ({ + name, title = '', label, description, @@ -45,7 +49,6 @@ export const CheckboxList = ({ maxHeight, }: CheckboxListProps) => { const { t } = useTranslation() - const groupLabelId = useId() const [searchQuery, setSearchQuery] = useState('') const filteredOptions = useMemo(() => { @@ -66,116 +69,129 @@ export const CheckboxList = ({ ) return ( -
- {label && ( -
- {label} -
- )} - {description && ( -
- {description} -
- )} - - onChange?.(nextValue)} - allValues={selectableOptionValues} - disabled={disabled} - className="rounded-lg border border-components-panel-border bg-components-panel-bg" + + onChange?.(nextValue)} + allValues={selectableOptionValues} + disabled={disabled} + className="flex flex-col gap-1" + /> + )} > - {(showSelectAll || title || showSearch) && ( -
- {!searchQuery && showSelectAll && ( -
) } -export default ApiBasedExtensionModal diff --git a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx index e98ff593cf..e346c84261 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/selector.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/selector.tsx @@ -1,36 +1,36 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { useModalContext } from '@/context/modal-context' -import { useApiBasedExtensions } from '@/service/use-common' -import ApiBasedExtensionModal from './modal' +import { consoleQuery } from '@/service/client' +import { ApiBasedExtensionModal } from './modal' type ApiBasedExtensionSelectorProps = { value: string onChange: (value: string) => void } -const ApiBasedExtensionSelector = ({ +export function ApiBasedExtensionSelector({ value, onChange, -}: ApiBasedExtensionSelectorProps) => { +}: ApiBasedExtensionSelectorProps) { const { t } = useTranslation() const [open, setOpen] = useState(false) const [addModalOpen, setAddModalOpen] = useState(false) const { setShowAccountSettingModal, } = useModalContext() - const { data, refetch: mutate } = useApiBasedExtensions() + const { data: apiBasedExtensions = [] } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions()) const handleSelect = (id: string) => { onChange(id) setOpen(false) } - const currentItem = data?.find(item => item.id === value) + const currentItem = apiBasedExtensions.find(item => item.id === value) - const handleSaveApiBasedExtension = () => { - mutate() + const handleApiBasedExtensionSaved = () => { setAddModalOpen(false) } const handleAddModalOpenChange = (nextOpen: boolean) => { @@ -96,12 +96,12 @@ const ApiBasedExtensionSelector = ({
{ - data?.map(item => ( + apiBasedExtensions.map(item => (
- + )} {state.step === InstallStepFromGitHub.selectPackage && ( = ({ return ( <> - - { + if (!value) + return + const selectedItem = versions.find(item => String(item.value) === value) + if (selectedItem) + onSelectVersion(selectedItem) + }} + > + +
+ + {selectedVersionOption?.name ?? t(`${i18nPrefix}.selectVersionPlaceholder`, { ns: 'plugin' }) ?? ''} + + {!!(updatePayload?.originalPackageInfo.version && selectedVersionOption && selectedVersionOption.value !== updatePayload.originalPackageInfo.version) && ( + + {updatePayload.originalPackageInfo.version} + {' '} + {'->'} + {' '} + {selectedVersionOption.value} + )} - - - ))} - - - - +
+
+ + {versions.map(item => ( + + {item.name} + {item.value === updatePayload?.originalPackageInfo.version && ( + INSTALLED + )} + + + ))} + + + + + + {t(`${i18nPrefix}.selectPackage`, { ns: 'plugin' })} + + +
{!isEdit && ( diff --git a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx index 88b65f3ab9..9c3c50bba2 100644 --- a/web/app/components/workflow/nodes/_base/components/form-input-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/form-input-item.tsx @@ -305,6 +305,7 @@ const FormInputItem: FC = ({ )} {isCheckbox && isConstant && ( { vi.resetModules() vi.doMock('@/utils/client', () => ({ isClient: isClientValue, isServer: !isClientValue })) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - // eslint-disable-next-line next/no-assign-module-variable const module = await import('./client') warnSpy.mockClear() return { getBaseURL: module.getBaseURL, warnSpy } @@ -35,6 +35,14 @@ const createTag = (overrides: Partial = {}): Tag => ({ ...overrides, }) +const createApiBasedExtension = (overrides: Partial = {}): ApiBasedExtensionResponse => ({ + id: 'extension-1', + name: 'Weather', + api_endpoint: 'https://api.example.com/weather', + api_key: 'secret-key', + ...overrides, +}) + // Scenario: base URL selection and warnings. describe('getBaseURL', () => { beforeEach(() => { @@ -258,3 +266,94 @@ describe('consoleQuery tag mutation defaults', () => { expect(queryClient.getQueryData(knowledgeListKey)).toEqual([knowledgeTag]) }) }) + +// Scenario: oRPC mutation defaults own shared API Extension cache behavior. +describe('consoleQuery apiBasedExtension mutation defaults', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should add created API Extension to the list query cache', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const listKey = consoleQuery.apiBasedExtension.get.queryKey() + const existingExtension = createApiBasedExtension({ id: 'extension-1', name: 'Existing' }) + const createdExtension = createApiBasedExtension({ id: 'extension-2', name: 'Created' }) + + queryClient.setQueryData(listKey, [existingExtension]) + + const mutationOptions = consoleQuery.apiBasedExtension.post.mutationOptions() + await mutationOptions.onSuccess?.( + createdExtension, + { + body: { + name: createdExtension.name, + api_endpoint: createdExtension.api_endpoint, + api_key: createdExtension.api_key, + }, + }, + undefined, + createMutationContext(queryClient), + ) + + expect(queryClient.getQueryData(listKey)).toEqual([createdExtension, existingExtension]) + }) + + it('should update matching API Extension in the list query cache', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const listKey = consoleQuery.apiBasedExtension.get.queryKey() + const targetExtension = createApiBasedExtension({ id: 'extension-1', name: 'Before' }) + const otherExtension = createApiBasedExtension({ id: 'extension-2', name: 'Other' }) + const updatedExtension = createApiBasedExtension({ ...targetExtension, name: 'After' }) + + queryClient.setQueryData(listKey, [targetExtension, otherExtension]) + + const mutationOptions = consoleQuery.apiBasedExtension.byId.post.mutationOptions() + await mutationOptions.onSuccess?.( + updatedExtension, + { + params: { + id: targetExtension.id, + }, + body: { + name: 'Ignored Client Name', + api_endpoint: targetExtension.api_endpoint, + api_key: '[__HIDDEN__]', + }, + }, + undefined, + createMutationContext(queryClient), + ) + + expect(queryClient.getQueryData(listKey)).toEqual([updatedExtension, otherExtension]) + }) + + it('should remove deleted API Extension from the list query cache', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const listKey = consoleQuery.apiBasedExtension.get.queryKey() + const deletedExtension = createApiBasedExtension({ id: 'extension-1', name: 'Delete me' }) + const remainingExtension = createApiBasedExtension({ id: 'extension-2', name: 'Keep me' }) + + queryClient.setQueryData(listKey, [deletedExtension, remainingExtension]) + + const mutationOptions = consoleQuery.apiBasedExtension.byId.delete.mutationOptions() + await mutationOptions.onSuccess?.( + {}, + { + params: { + id: deletedExtension.id, + }, + }, + undefined, + createMutationContext(queryClient), + ) + + expect(queryClient.getQueryData(listKey)).toEqual([remainingExtension]) + }) +}) diff --git a/web/service/client.ts b/web/service/client.ts index 984779e2b2..84a8c7a43d 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -1,3 +1,4 @@ +import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen' import type { ContractRouterClient } from '@orpc/contract' import type { JsonifiedClient } from '@orpc/openapi-client' import type { Tag } from '@/contract/console/tags' @@ -89,6 +90,45 @@ export const consoleClient: JsonifiedClient { + context.client.setQueryData( + consoleQuery.apiBasedExtension.get.queryKey(), + (oldExtensions: ApiBasedExtensionResponse[] | undefined) => + oldExtensions ? [createdExtension, ...oldExtensions] : oldExtensions, + ) + }, + }, + }, + byId: { + post: { + mutationOptions: { + onSuccess: (updatedExtension, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.apiBasedExtension.get.queryKey(), + (oldExtensions: ApiBasedExtensionResponse[] | undefined) => + oldExtensions?.map(extension => extension.id === variables.params.id + ? updatedExtension + : extension), + ) + }, + }, + }, + delete: { + mutationOptions: { + onSuccess: (_data, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.apiBasedExtension.get.queryKey(), + (oldExtensions: ApiBasedExtensionResponse[] | undefined) => + oldExtensions?.filter(extension => extension.id !== variables.params.id), + ) + }, + }, + }, + }, + }, tags: { create: { mutationOptions: { diff --git a/web/service/common.ts b/web/service/common.ts index 57304712dd..3f0ae66a9b 100644 --- a/web/service/common.ts +++ b/web/service/common.ts @@ -1,8 +1,3 @@ -import type { - ApiBasedExtensionListResponse, - ApiBasedExtensionPayload, - ApiBasedExtensionResponse, -} from '@dify/contracts/api/console/api-based-extension/types.gen' import type { DefaultModelResponse, Model, @@ -275,26 +270,6 @@ export const fetchDataSourceNotionBinding = (url: string): Promise<{ result: str return get<{ result: string }>(url) } -export const fetchApiBasedExtensionList = (url: string): Promise => { - return get(url) -} - -export const fetchApiBasedExtensionDetail = (url: string): Promise => { - return get(url) -} - -export const addApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise => { - return post(url, { body }) -} - -export const updateApiBasedExtension = ({ url, body }: { url: string, body: ApiBasedExtensionPayload }): Promise => { - return post(url, { body }) -} - -export const deleteApiBasedExtension = (url: string): Promise<{ result: string }> => { - return del<{ result: string }>(url) -} - export const fetchCodeBasedExtensionList = (url: string): Promise => { return get(url) } diff --git a/web/service/use-common.ts b/web/service/use-common.ts index 9e39d090b5..2fb0a8cbda 100644 --- a/web/service/use-common.ts +++ b/web/service/use-common.ts @@ -23,7 +23,6 @@ import type { RETRIEVE_METHOD } from '@/types/app' import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { IS_DEV } from '@/config' import { get, post } from './base' -import { consoleClient } from './client' /** * True iff `err` is a 401 Response thrown by `service/base.ts`. @@ -52,7 +51,6 @@ export const commonQueryKeys = { accountIntegrates: [NAME_SPACE, 'account-integrates'] as const, pluginProviders: [NAME_SPACE, 'plugin-providers'] as const, notionConnection: [NAME_SPACE, 'notion-connection'] as const, - apiBasedExtensions: [NAME_SPACE, 'api-based-extensions'] as const, codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const, invitationCheck: (params?: { workspace_id?: string, email?: string, token?: string }) => [ NAME_SPACE, @@ -319,13 +317,6 @@ export const useCodeBasedExtensions = (module: string) => { }) } -export const useApiBasedExtensions = () => { - return useQuery({ - queryKey: commonQueryKeys.apiBasedExtensions, - queryFn: () => consoleClient.apiBasedExtension.get(), - }) -} - export const useInvitationCheck = (params?: { workspace_id?: string, email?: string, token?: string }, enabled?: boolean) => { return useQuery({ queryKey: commonQueryKeys.invitationCheck(params),