diff --git a/api/controllers/console/extension.py b/api/controllers/console/extension.py index 0c9a93c1cd9bc4..e53bb95c24e11d 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 @@ 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/") @@ -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") 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 00000000000000..e7852b8fe122b3 --- /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 0d1fb39348410d..60a7ea5bb563f3 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 07504d2754b6b7..800bbc746bc688 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 010fb3e56df9a5..8592622e6f2703 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 ee2089657007f7..d58fb954e0342e 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 b5d8e65c995ccc..4e98f428619ce6 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 623ae62c98854d..ea4e638babd11e 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 97cd1a1d60dae6..adf4f28388efbf 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 f9caa0d8c5d5c2..24556a11e7723a 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 00000000000000..95692123606d80 --- /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 00000000000000..84b6ac977d9f66 --- /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 00000000000000..12f0ba6ce8ee69 --- /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 00000000000000..9ee90af3066c28 --- /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 00000000000000..51410ed24c7999 --- /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 00000000000000..e51804509b4a14 --- /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 00000000000000..6ce3f6b7e2ec90 --- /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 00000000000000..f1edac5e7ce381 --- /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 00000000000000..09cdc2a623931a --- /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 a436d997e626b5..b9472943e0495b 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 6dc832a291fbbc..697266dcec2695 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 a48e202142eff3..844a9844064ea5 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 f43b9ae15450af..4d47ef688e979e 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 f2a2d24e57e1a8..6a4c2f42862e89 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 2a725d88ca047d..fd87ab0aebc669 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 b09b7b1c7090d6..16e3633e3e1a8d 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 7d536a766a8f54..feccbd0b380b1b 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 22cd1a57185f6e..0b809e3792e374 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 e98ff593cf0db8..e346c842612934 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 88b65f3ab9a462..9c3c50bba2891a 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 984779e2b2b6a9..84a8c7a43df933 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 57304712dd1407..3f0ae66a9bdf4a 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 9e39d090b54ee8..2fb0a8cbda581b 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),