diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/__tests__/DBProvidersPage.test.tsx b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/__tests__/DBProvidersPage.test.tsx new file mode 100644 index 000000000000..b35d81ccc747 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/__tests__/DBProvidersPage.test.tsx @@ -0,0 +1,338 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { GlobalVariable } from "@/types/global_variables"; +import DBProvidersPage from "../index"; + +// --------------------------------------------------------------------------- +// Characterization (golden-master) suite for the untouched 866-line page. +// It pins the observable behavior BEFORE the WP2 extraction so every +// extraction commit can be validated against it. No implementation details +// are asserted — only what a user (or consumer) can observe. +// --------------------------------------------------------------------------- + +let mockGlobalVariables: GlobalVariable[] = []; + +const postCalls: Array> = []; +const patchCalls: Array> = []; +const mockPostMutateAsync = jest.fn((params: Record) => { + postCalls.push(params); + return Promise.resolve(undefined); +}); +const mockPatchMutateAsync = jest.fn((params: Record) => { + patchCalls.push(params); + return Promise.resolve(undefined); +}); +const mockTestMutateAsync = jest.fn(() => + Promise.resolve({ ok: true, message: "cluster green" }), +); + +const mockSetSuccessData = jest.fn(); +const mockSetErrorData = jest.fn(); + +jest.mock("@/controllers/API/queries/variables", () => ({ + useGetGlobalVariables: () => ({ data: mockGlobalVariables }), + usePostGlobalVariables: () => ({ + mutateAsync: mockPostMutateAsync, + isPending: false, + }), + usePatchGlobalVariables: () => ({ + mutateAsync: mockPatchMutateAsync, + isPending: false, + }), +})); + +jest.mock( + "@/controllers/API/queries/knowledge-bases/use-test-kb-connection", + () => ({ + useTestDBProviderConnection: () => ({ + mutateAsync: mockTestMutateAsync, + isPending: false, + }), + }), +); + +jest.mock("@/stores/alertStore", () => ({ + __esModule: true, + default: (selector: (state: Record) => unknown) => + selector({ + setSuccessData: mockSetSuccessData, + setErrorData: mockSetErrorData, + }), +})); + +jest.mock("@/components/common/genericIconComponent", () => ({ + __esModule: true, + default: ({ name }: { name: string }) => ( + + ), +})); + +const STRINGS: Record = { + "settings.dbProviders.title": "DB Providers", + "settings.dbProviders.description": + "Configure vector-store providers for Knowledge Bases.", + "settings.dbProviders.active": "Active", + "settings.dbProviders.comingSoon": "Coming soon", + "settings.dbProviders.comingSoonDescription": + "This provider is stubbed in the Knowledge Base backend registry and will become configurable after the provider implementation is wired through end-to-end.", + "settings.dbProviders.chromaDescription": + "Chroma stores vectors on disk next to Langflow and is enabled by default. Selecting it here makes it the default provider for new Knowledge Bases.", + "settings.dbProviders.chromaSelected": "Chroma selected", + "settings.dbProviders.useChroma": "Use Chroma", + "settings.dbProviders.save": "Save", + "settings.dbProviders.useProvider": "Use {{provider}}", + "settings.dbProviders.saveAndUseProvider": "Save and use {{provider}}", + "settings.dbProviders.testConnection": "Test connection", + "settings.dbProviders.savedAsGlobalVariable": "Saved as global variable", + "settings.dbProviders.errorMissingConfig": "Missing required configuration", + "settings.dbProviders.configSaved": "{{provider}} configuration saved", +}; + +jest.mock("react-i18next", () => ({ + // Consumed by src/i18n.ts, which is pulled in transitively through + // the ui/badge → utils import chain. + initReactI18next: { type: "3rdParty", init: () => {} }, + useTranslation: () => ({ + t: (key: string, options?: Record) => { + const template = STRINGS[key] ?? (options?.defaultValue as string) ?? key; + return template.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => + String(options?.[name] ?? ""), + ); + }, + }), +})); + +const variable = ( + name: string, + value: string | undefined, + id = `id-${name}`, +): GlobalVariable => + ({ id, name, value, type: "Generic" }) as unknown as GlobalVariable; + +const openSearchRequiredSaved = (): GlobalVariable[] => [ + variable("OPENSEARCH_URL", "https://localhost:9200"), + variable("OPENSEARCH_USERNAME", "admin"), + // Credential values come back masked/absent from the API — existence + // of the variable is what the page keys off for secrets. + variable("OPENSEARCH_PASSWORD", undefined), + variable("OPENSEARCH_INDEX_NAME", "langflow"), +]; + +const getSaveButton = (name: RegExp) => screen.getByRole("button", { name }); + +beforeEach(() => { + mockGlobalVariables = []; + postCalls.length = 0; + patchCalls.length = 0; + jest.clearAllMocks(); +}); + +describe("DBProvidersPage characterization", () => { + describe("provider list", () => { + it("renders the six providers in canonical order", () => { + render(); + const items = screen.getAllByTestId(/^db-provider-item-/); + expect(items.map((item) => item.getAttribute("data-testid"))).toEqual([ + "db-provider-item-chroma", + "db-provider-item-chroma_cloud", + "db-provider-item-opensearch", + "db-provider-item-astra", + "db-provider-item-mongodb", + "db-provider-item-postgres", + ]); + }); + + it("marks chroma as the active provider when no backend variable exists", () => { + render(); + const chromaItem = screen.getByTestId("db-provider-item-chroma"); + expect(chromaItem).toHaveTextContent("Active"); + expect( + chromaItem.querySelector('[data-testid="icon-Check"]'), + ).toBeInTheDocument(); + }); + + it("marks the provider stored in LANGFLOW_KNOWLEDGE_BACKEND as active", () => { + mockGlobalVariables = [ + variable("LANGFLOW_KNOWLEDGE_BACKEND", "opensearch"), + ...openSearchRequiredSaved(), + ]; + render(); + expect( + screen.getByTestId("db-provider-item-opensearch"), + ).toHaveTextContent("Active"); + expect( + screen.getByTestId("db-provider-item-chroma"), + ).not.toHaveTextContent("Active"); + }); + + it("shows a coming-soon badge for astra, mongodb and postgres", () => { + render(); + for (const id of ["astra", "mongodb", "postgres"]) { + expect(screen.getByTestId(`db-provider-item-${id}`)).toHaveTextContent( + "Coming soon", + ); + } + }); + }); + + describe("chroma panel (default selection)", () => { + it("shows a disabled 'Chroma selected' action while chroma is active", () => { + render(); + const button = getSaveButton(/chroma selected/i); + expect(button).toBeDisabled(); + expect(screen.queryByTestId("db-provider-test-connection")).toBeNull(); + }); + }); + + describe("opensearch panel", () => { + const openOpenSearch = async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("db-provider-item-opensearch")); + return user; + }; + + it("renders text fields with variable keys, defaults and both TLS toggles", async () => { + await openOpenSearch(); + for (const key of [ + "OPENSEARCH_URL", + "OPENSEARCH_USERNAME", + "OPENSEARCH_PASSWORD", + "OPENSEARCH_INDEX_NAME", + "OPENSEARCH_VECTOR_FIELD", + "OPENSEARCH_TEXT_FIELD", + ]) { + expect(screen.getByText(key)).toBeInTheDocument(); + } + // Optional fields come pre-filled with their defaults. + expect(screen.getByDisplayValue("vector_field")).toBeInTheDocument(); + expect(screen.getByDisplayValue("text")).toBeInTheDocument(); + // Both TLS toggles render checked (defaultValue true). + expect( + screen.getByTestId("db-provider-toggle-OPENSEARCH_USE_SSL"), + ).toHaveAttribute("aria-checked", "true"); + expect( + screen.getByTestId("db-provider-toggle-OPENSEARCH_VERIFY_CERTS"), + ).toHaveAttribute("aria-checked", "true"); + }); + + it("keeps Save and Test connection disabled until every required field has a value", async () => { + const user = await openOpenSearch(); + const save = getSaveButton(/save and use opensearch/i); + const test = screen.getByTestId("db-provider-test-connection"); + expect(save).toBeDisabled(); + expect(test).toBeDisabled(); + + const [url, username, indexName] = [ + "https://localhost:9200", + "admin", + "langflow", + ]; + const textboxes = screen.getAllByRole("textbox"); + // textboxes: URL, Username, Index name, Vector field, Text field + await user.type(textboxes[0], url); + await user.type(textboxes[1], username); + expect(save).toBeDisabled(); + const password = document.querySelector('input[type="password"]'); + await user.type(password as Element, "secret"); + await user.type(textboxes[2], indexName); + + expect(save).toBeEnabled(); + expect(test).toBeEnabled(); + }); + + it("masks a configured secret and offers plain activation when hydrated", async () => { + mockGlobalVariables = openSearchRequiredSaved(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("db-provider-item-opensearch")); + + const password = document.querySelector('input[type="password"]'); + expect(password).toHaveValue("••••••••"); + // Hydrated + no session edits → button switches to plain activation. + const useButton = getSaveButton(/^use opensearch$/i); + expect(useButton).toBeEnabled(); + }); + + it("saves new required fields as global variables and activates the provider", async () => { + const user = await openOpenSearch(); + const textboxes = screen.getAllByRole("textbox"); + await user.type(textboxes[0], "https://localhost:9200"); + await user.type(textboxes[1], "admin"); + await user.type( + document.querySelector('input[type="password"]') as Element, + "secret", + ); + await user.type(textboxes[2], "langflow"); + + await user.click(getSaveButton(/save and use opensearch/i)); + + await waitFor(() => { + expect(mockSetSuccessData).toHaveBeenCalledWith({ + title: "OpenSearch configuration saved", + }); + }); + + const byName = Object.fromEntries( + postCalls.map((call) => [call.name, call]), + ); + expect(byName.OPENSEARCH_URL).toMatchObject({ + value: "https://localhost:9200", + type: "Generic", + }); + expect(byName.OPENSEARCH_USERNAME).toMatchObject({ value: "admin" }); + // Secrets persist as Credential-type variables. + expect(byName.OPENSEARCH_PASSWORD).toMatchObject({ + value: "secret", + type: "Credential", + }); + expect(byName.OPENSEARCH_INDEX_NAME).toMatchObject({ + value: "langflow", + }); + // Activation writes the backend selector variable. + expect(byName.LANGFLOW_KNOWLEDGE_BACKEND).toMatchObject({ + value: "opensearch", + }); + expect(patchCalls).toHaveLength(0); + }); + + it("updates existing variables through PATCH instead of re-creating them", async () => { + mockGlobalVariables = openSearchRequiredSaved(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("db-provider-item-opensearch")); + + const textboxes = screen.getAllByRole("textbox"); + await user.clear(textboxes[0]); + await user.type(textboxes[0], "https://other:9200"); + await user.click(getSaveButton(/save and use opensearch/i)); + + await waitFor(() => { + expect( + patchCalls.some( + (call) => + call.id === "id-OPENSEARCH_URL" && + call.value === "https://other:9200", + ), + ).toBe(true); + }); + // The untouched, already-saved fields are not re-created. + expect( + postCalls.filter((call) => call.name === "OPENSEARCH_PASSWORD"), + ).toHaveLength(0); + }); + }); + + describe("coming-soon panel", () => { + it("shows the stub description and no actions for astra", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("db-provider-item-astra")); + expect( + screen.getByText(/stubbed in the Knowledge Base backend registry/), + ).toBeInTheDocument(); + expect(screen.queryByTestId("db-provider-test-connection")).toBeNull(); + expect(screen.queryByRole("button", { name: /Save/ })).toBeNull(); + }); + }); +}); diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/BooleanFieldRow.tsx b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/BooleanFieldRow.tsx new file mode 100644 index 000000000000..df468d41aff2 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/BooleanFieldRow.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from "react-i18next"; +import { Switch } from "@/components/ui/switch"; +import type { DBProviderBooleanField } from "@/constants/dbProviderConstants"; + +export function BooleanFieldRow({ + field, + value, + disabled, + onChange, +}: { + field: DBProviderBooleanField; + value: boolean; + disabled: boolean; + onChange: (checked: boolean) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ + {t(`settings.dbProviders.fields.${field.variableKey}.label`, { + defaultValue: field.label, + })} + + {field.helperText && ( + + {t(`settings.dbProviders.fields.${field.variableKey}.helperText`, { + defaultValue: field.helperText, + })} + + )} + + {t("settings.dbProviders.savedAsGlobalVariable")}{" "} + {field.variableKey} + +
+ +
+ ); +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/ProviderConfigurationPanel.tsx b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/ProviderConfigurationPanel.tsx new file mode 100644 index 000000000000..659715402f8e --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/ProviderConfigurationPanel.tsx @@ -0,0 +1,197 @@ +import { useTranslation } from "react-i18next"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + type AvailableDBProviderId, + type DBProviderConfigField, + type DBProviderOption, + getGlobalVariableValue, +} from "@/constants/dbProviderConstants"; +import type { GlobalVariable } from "@/types/global_variables"; +import { BooleanFieldRow } from "./BooleanFieldRow"; +import { TextFieldRow } from "./TextFieldRow"; + +export function ProviderConfigurationPanel({ + provider, + activeProviderId, + globalVariables, + variableValues, + editingSecret, + isPending, + canSave, + isHydrated, + getFieldValue, + onVariableChange, + onSecretEditingChange, + onSave, + onTestConnection, + isTesting, +}: { + provider: DBProviderOption; + activeProviderId: AvailableDBProviderId; + globalVariables: GlobalVariable[]; + variableValues: Record; + editingSecret: Record; + isPending: boolean; + canSave: boolean; + isHydrated: boolean; + getFieldValue: (field: DBProviderConfigField) => string; + onVariableChange: (key: string, value: string) => void; + onSecretEditingChange: (key: string, editing: boolean) => void; + onSave: () => void; + onTestConnection?: () => void; + isTesting: boolean; +}) { + const { t } = useTranslation(); + const isComingSoon = provider.status === "coming_soon"; + const isActive = activeProviderId === provider.id; + // True when the user has interacted with any field this session. + const hasUnsavedChanges = Object.keys(variableValues).length > 0; + // Label for the primary action button: + // · Active provider → "Save" (persist any edits) + // · Hydrated + no edits → "Use " (just switch active provider) + // · Otherwise → "Save and use " (persist + activate) + const saveButtonLabel = isActive + ? t("settings.dbProviders.save") + : isHydrated && !hasUnsavedChanges + ? t("settings.dbProviders.useProvider", { provider: provider.label }) + : t("settings.dbProviders.saveAndUseProvider", { + provider: provider.label, + }); + + return ( +
+
+
+ +
+
+ + {provider.label} + + {isActive && !isComingSoon && ( + + {t("settings.dbProviders.active")} + + )} + {isComingSoon && ( + + {t("settings.dbProviders.comingSoon")} + + )} +
+ + {t(`settings.dbProviders.providers.${provider.id}.description`, { + defaultValue: provider.description, + })} + +
+
+
+ + {isComingSoon ? ( +
+ {t("settings.dbProviders.comingSoonDescription")} +
+ ) : provider.id === "chroma" ? ( +
+
+ {t("settings.dbProviders.chromaDescription")} +
+
+ +
+
+ ) : ( +
+ {provider.configFields.map((field) => + field.kind === "boolean" ? ( + + onVariableChange( + field.variableKey, + checked ? "true" : "false", + ) + } + /> + ) : ( + v.name === field.variableKey) + } + disabled={isPending} + onChange={(value) => onVariableChange(field.variableKey, value)} + onFocus={() => { + // Use variable existence (not its returned value) as the + // gate — credential-type variables may have their value + // masked in the API response. + const isConfigured = + field.isSecret && + globalVariables.some((v) => v.name === field.variableKey); + if (isConfigured && !(field.variableKey in variableValues)) { + onSecretEditingChange(field.variableKey, true); + onVariableChange(field.variableKey, ""); + } + }} + onBlur={() => { + if (!variableValues[field.variableKey]) { + onSecretEditingChange(field.variableKey, false); + } + }} + /> + ), + )} +
+ {onTestConnection && ( + + )} + +
+
+ )} +
+ ); +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/ProviderListItem.tsx b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/ProviderListItem.tsx new file mode 100644 index 000000000000..f3a926809a6e --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/ProviderListItem.tsx @@ -0,0 +1,75 @@ +import { useTranslation } from "react-i18next"; +import ForwardedIconComponent from "@/components/common/genericIconComponent"; +import { Badge } from "@/components/ui/badge"; +import type { DBProviderOption } from "@/constants/dbProviderConstants"; +import { cn } from "@/utils/utils"; + +export function ProviderListItem({ + provider, + isActive, + isSelected, + isConfigured, + onSelect, +}: { + provider: DBProviderOption; + isActive: boolean; + isSelected: boolean; + isConfigured: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(); + const isComingSoon = provider.status === "coming_soon"; + const isMuted = (!isConfigured || isComingSoon) && !provider.defaultEnabled; + + return ( + + ); +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/TextFieldRow.tsx b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/TextFieldRow.tsx new file mode 100644 index 000000000000..3b263836c7a4 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/components/TextFieldRow.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import type { DBProviderTextField } from "@/constants/dbProviderConstants"; + +const MASKED_VALUE = "••••••••"; + +export function TextFieldRow({ + field, + value, + hasNewValue, + isEditingSecret, + existingValue, + isSecretConfigured, + disabled, + onChange, + onFocus, + onBlur, +}: { + field: DBProviderTextField; + value: string; + hasNewValue: boolean; + isEditingSecret: boolean; + existingValue: string | undefined; + isSecretConfigured?: boolean; + disabled: boolean; + onChange: (value: string) => void; + onFocus: () => void; + onBlur: () => void; +}) { + // Show redacted dots when a secret is configured (variable exists) and + // the user is neither actively editing nor has typed a new value this + // session. Use ``isSecretConfigured`` (variable existence) rather than + // ``existingValue`` (returned API value) because credential-type + // variables are not exposed in the global-variables API response. + const { t } = useTranslation(); + const shouldMask = + field.isSecret && + (isSecretConfigured ?? !!existingValue) && + !hasNewValue && + !isEditingSecret; + const inputValue = shouldMask ? MASKED_VALUE : value; + + return ( + + ); +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/helpers/build-backend-config-payload.ts b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/helpers/build-backend-config-payload.ts new file mode 100644 index 000000000000..f825a8526f6f --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/helpers/build-backend-config-payload.ts @@ -0,0 +1,41 @@ +import { + type AvailableDBProviderId, + CHROMA_CLOUD_VARIABLES, + OPENSEARCH_VARIABLES, +} from "@/constants/dbProviderConstants"; + +// Build a ``backend_config`` payload for ``POST /test-connection`` from +// the in-memory form values, side-stepping the global-variable cache +// which is stale immediately after Save. The server-side test still +// resolves credentials (URL/USERNAME/PASSWORD) through variable_service +// using the variable-name fields below — those names are stable +// constants and don't depend on what the user typed. +export function buildBackendConfigPayload( + providerId: AvailableDBProviderId, + literalFields: Record, + booleanFields: Record, +): Record { + if (providerId === "chroma_cloud") { + return { + mode: "cloud", + tenant_variable: CHROMA_CLOUD_VARIABLES.TENANT, + database_variable: CHROMA_CLOUD_VARIABLES.DATABASE, + api_key_variable: CHROMA_CLOUD_VARIABLES.API_KEY, + cloud_region: literalFields[CHROMA_CLOUD_VARIABLES.REGION] || "us-east-1", + }; + } + if (providerId !== "opensearch") { + return {}; + } + return { + url_variable: OPENSEARCH_VARIABLES.URL, + username_variable: OPENSEARCH_VARIABLES.USERNAME, + password_variable: OPENSEARCH_VARIABLES.PASSWORD, + index_name: literalFields[OPENSEARCH_VARIABLES.INDEX_NAME] || "", + vector_field: + literalFields[OPENSEARCH_VARIABLES.VECTOR_FIELD] || "vector_field", + text_field: literalFields[OPENSEARCH_VARIABLES.TEXT_FIELD] || "text", + use_ssl: booleanFields[OPENSEARCH_VARIABLES.USE_SSL] ?? true, + verify_certs: booleanFields[OPENSEARCH_VARIABLES.VERIFY_CERTS] ?? true, + }; +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderActions.ts b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderActions.ts new file mode 100644 index 000000000000..0b7788c551d5 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderActions.ts @@ -0,0 +1,257 @@ +import type { Dispatch, SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; +import { + type AvailableDBProviderId, + DB_PROVIDER_OPTIONS, + type DBProviderConfigField, + type DBProviderId, + type DBProviderOption, + type DBProviderTextField, + toAPIBackendType, +} from "@/constants/dbProviderConstants"; +import { useTestDBProviderConnection } from "@/controllers/API/queries/knowledge-bases/use-test-kb-connection"; +import useAlertStore from "@/stores/alertStore"; +import { buildBackendConfigPayload } from "../helpers/build-backend-config-payload"; + +type ApiError = { + response?: { + data?: { + detail?: string; + }; + }; +}; + +const getErrorDetail = (error: unknown) => + (error as ApiError)?.response?.data?.detail || + "An unexpected error occurred. Please try again."; + +/** + * Save / Test Connection / Use Chroma flows for the DB Providers page. + * Pure orchestration over the variable primitives and field resolution + * owned by the page — no rendering concerns. + */ +export function useDBProviderActions({ + selectedProvider, + canSave, + getFieldValue, + variableValues, + setVariable, + activateProvider, + setVariableValues, + setEditingSecret, + setHasManuallySelectedProvider, + setSelectedProviderId, +}: { + selectedProvider: DBProviderOption; + canSave: boolean; + getFieldValue: (field: DBProviderConfigField) => string; + variableValues: Record; + setVariable: (params: { + name: string; + value: string; + isSecret: boolean; + }) => Promise; + activateProvider: (provider: DBProviderOption) => Promise; + setVariableValues: Dispatch>>; + setEditingSecret: Dispatch>>; + setHasManuallySelectedProvider: Dispatch>; + setSelectedProviderId: Dispatch>; +}) { + const { t } = useTranslation(); + const { mutateAsync: testProviderConnection, isPending: isTesting } = + useTestDBProviderConnection(); + + const setSuccessData = useAlertStore((state) => state.setSuccessData); + const setErrorData = useAlertStore((state) => state.setErrorData); + + // Returns ``true`` if the save fully succeeded so callers (the Test + // Connection button) can chain a follow-up step. Errors are surfaced + // via toast inside the function — callers should not duplicate them. + // + // ``skipActivation`` lets the Test Connection flow persist credentials + // (so server-side variable_service can resolve them) without switching + // the active provider — testing should never silently change settings. + const handleSave = async (options?: { + silent?: boolean; + skipActivation?: boolean; + }): Promise => { + if (selectedProvider.status !== "available") return false; + if (!canSave) { + setErrorData({ + title: t("settings.dbProviders.errorMissingConfig"), + list: [ + `${selectedProvider.label} requires ${selectedProvider.configFields + .filter( + (field): field is DBProviderTextField => + field.kind !== "boolean" && + field.required && + !getFieldValue(field).trim(), + ) + .map((field) => field.label) + .join(", ")}.`, + ], + }); + return false; + } + + try { + const fieldsToSave = selectedProvider.configFields.filter((field) => { + if (field.kind === "boolean") { + // Persist booleans only when the user actually flipped them + // this session — otherwise we'd write the default to a + // global variable on every save, polluting the variables + // page with values the user never set. + return field.variableKey in variableValues; + } + const nextValue = getFieldValue(field).trim(); + return ( + nextValue && (field.variableKey in variableValues || field.required) + ); + }); + + await Promise.all( + fieldsToSave.map((field) => { + const value = + field.kind === "boolean" + ? getFieldValue(field) // already "true" / "false" + : getFieldValue(field).trim(); + return setVariable({ + name: field.variableKey, + value, + isSecret: field.kind === "boolean" ? false : field.isSecret, + }); + }), + ); + if (!options?.skipActivation) { + await activateProvider(selectedProvider); + // Resetting these on a non-activating save would re-snap the + // selected panel back to the active provider via the + // ``activeProviderId`` useEffect — that's correct after a real + // Save, but wrong when we're only persisting credentials for + // a Test Connection round-trip on a non-active provider. + setVariableValues({}); + setEditingSecret({}); + setHasManuallySelectedProvider(false); + } + if (!options?.silent) { + setSuccessData({ + title: + selectedProvider.id === "chroma" + ? t("settings.dbProviders.chromaSelected") + : t("settings.dbProviders.configSaved", { + provider: selectedProvider.label, + }), + }); + } + return true; + } catch (error: unknown) { + setErrorData({ + title: t("settings.dbProviders.errorSaving"), + list: [getErrorDetail(error)], + }); + return false; + } + }; + + const handleTestConnection = async () => { + if (selectedProvider.status !== "available") return; + if (!canSave) { + setErrorData({ + title: t("settings.dbProviders.errorMissingConfig"), + list: [ + `${selectedProvider.label} requires ${selectedProvider.configFields + .filter( + (field): field is DBProviderTextField => + field.kind !== "boolean" && + field.required && + !getFieldValue(field).trim(), + ) + .map((field) => field.label) + .join(", ")}.`, + ], + }); + return; + } + + // Build the test payload from current form state BEFORE saving — + // ``handleSave`` clears ``variableValues`` on success, so reading + // form values afterward would surface stale globals instead of the + // user's just-saved draft. The backend_config still references + // credential VARIABLE NAMES (URL/USERNAME/PASSWORD), so the + // server-side variable_service lookup happens after Save persists + // them. + const literalFields: Record = {}; + const booleanFields: Record = {}; + for (const field of selectedProvider.configFields) { + const raw = getFieldValue(field); + if (field.kind === "boolean") { + booleanFields[field.variableKey] = raw === "true"; + } else { + literalFields[field.variableKey] = raw.trim(); + } + } + + // Persist the variables first; the server-side test reads + // OPENSEARCH_URL etc. through ``variable_service``. Skip the + // activation step — testing must not switch the active provider. + const saved = await handleSave({ silent: true, skipActivation: true }); + if (!saved) { + // ``handleSave`` already surfaced an error toast. + return; + } + + try { + const backendConfig = buildBackendConfigPayload( + selectedProvider.id as AvailableDBProviderId, + literalFields, + booleanFields, + ); + const response = await testProviderConnection({ + backend_type: toAPIBackendType( + selectedProvider.id as AvailableDBProviderId, + ), + backend_config: backendConfig, + }); + if (response.ok) { + // ``setSuccessData`` only takes a title; pack any backend + // detail (cluster name, version) into the title so it shows. + setSuccessData({ + title: response.message + ? t("settings.dbProviders.connectionSuccessfulWith", { + message: response.message, + }) + : t("settings.dbProviders.connectionSuccessful"), + }); + } else { + setErrorData({ + title: t("settings.dbProviders.connectionFailed"), + list: [ + response.message || t("settings.dbProviders.connectionRejected"), + ], + }); + } + } catch (error: unknown) { + setErrorData({ + title: t("settings.dbProviders.errorTesting"), + list: [getErrorDetail(error)], + }); + } + }; + + const handleUseChroma = async () => { + const chromaProvider = DB_PROVIDER_OPTIONS[0]; + try { + await activateProvider(chromaProvider); + setSelectedProviderId("chroma"); + setHasManuallySelectedProvider(false); + setSuccessData({ title: t("settings.dbProviders.chromaSelected") }); + } catch (error: unknown) { + setErrorData({ + title: t("settings.dbProviders.errorSelectingChroma"), + list: [getErrorDetail(error)], + }); + } + }; + + return { handleSave, handleTestConnection, handleUseChroma, isTesting }; +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderFields.ts b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderFields.ts new file mode 100644 index 000000000000..bbea0b8761d5 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderFields.ts @@ -0,0 +1,83 @@ +import { + type DBProviderConfigField, + type DBProviderOption, + type DBProviderTextField, + getGlobalVariableValue, + parseBooleanGlobalVariable, +} from "@/constants/dbProviderConstants"; +import type { GlobalVariable } from "@/types/global_variables"; + +/** + * Field-value resolution and save-gating for the selected provider: + * session edits (``variableValues``) win over stored globals, which win + * over field defaults. + */ +export function useDBProviderFields({ + selectedProvider, + globalVariables, + variableValues, +}: { + selectedProvider: DBProviderOption; + globalVariables: GlobalVariable[]; + variableValues: Record; +}) { + const getFieldValue = (field: DBProviderConfigField): string => { + if (field.variableKey in variableValues) { + return variableValues[field.variableKey]; + } + if (field.kind === "boolean") { + const stored = parseBooleanGlobalVariable( + globalVariables, + field.variableKey, + field.defaultValue, + ); + return stored ? "true" : "false"; + } + return ( + getGlobalVariableValue(globalVariables, field.variableKey) ?? + field.defaultValue ?? + "" + ); + }; + + const hasConfiguredValue = (variableKey: string) => + globalVariables.some((variable) => variable.name === variableKey); + + // True when every required field already has a saved variable so the + // provider can be activated / tested without the user re-entering anything. + // Secret fields are checked by existence (API responses mask the value); + // non-secret fields are checked by stored value. + const isHydrated = selectedProvider.configFields + .filter( + (field): field is DBProviderTextField => + field.kind !== "boolean" && field.required, + ) + .every((field) => + field.isSecret + ? hasConfiguredValue(field.variableKey) + : Boolean( + getGlobalVariableValue(globalVariables, field.variableKey)?.trim(), + ), + ); + + // Boolean fields always have a defined value (toggle is never blank), + // so they don't gate the save button — only required text fields do. + // Secret fields satisfy the check when the user has typed a new value OR + // the variable already exists (API response masks saved secrets). + const canSave = selectedProvider.configFields + .filter( + (field): field is DBProviderTextField => + field.kind !== "boolean" && field.required, + ) + .every((field) => { + if (field.isSecret) { + return ( + Boolean(variableValues[field.variableKey]?.trim()) || + hasConfiguredValue(field.variableKey) + ); + } + return Boolean(getFieldValue(field).trim()); + }); + + return { getFieldValue, hasConfiguredValue, isHydrated, canSave }; +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderVariables.ts b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderVariables.ts new file mode 100644 index 000000000000..ba868e074259 --- /dev/null +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/hooks/useDBProviderVariables.ts @@ -0,0 +1,79 @@ +import { + ACTIVE_DB_PROVIDER_VARIABLE, + type DBProviderOption, +} from "@/constants/dbProviderConstants"; +import { VARIABLE_CATEGORY } from "@/constants/providerConstants"; +import { + useGetGlobalVariables, + usePatchGlobalVariables, + usePostGlobalVariables, +} from "@/controllers/API/queries/variables"; + +/** + * Global-variable plumbing for the DB Providers page: the variables + * query plus the create-or-update primitives the page persists through. + */ +export function useDBProviderVariables() { + const { data: globalVariables = [] } = useGetGlobalVariables(); + + const { mutateAsync: createGlobalVariable, isPending: isCreating } = + usePostGlobalVariables(); + const { mutateAsync: updateGlobalVariable, isPending: isUpdating } = + usePatchGlobalVariables(); + + const isPending = isCreating || isUpdating; + + const findVariable = (name: string) => + globalVariables.find((variable) => variable.name === name); + + const setVariable = async ({ + name, + value, + isSecret, + }: { + name: string; + value: string; + isSecret: boolean; + }) => { + const existingVariable = findVariable(name); + if (existingVariable) { + await updateGlobalVariable({ id: existingVariable.id, value }); + return; + } + + await createGlobalVariable({ + name, + value, + type: isSecret ? "Credential" : "Generic", + category: VARIABLE_CATEGORY.GLOBAL, + default_fields: [], + }); + }; + + const activateProvider = async (provider: DBProviderOption) => { + const activeProviderVariable = findVariable(ACTIVE_DB_PROVIDER_VARIABLE); + if (activeProviderVariable) { + await updateGlobalVariable({ + id: activeProviderVariable.id, + value: provider.id, + }); + return; + } + + await createGlobalVariable({ + name: ACTIVE_DB_PROVIDER_VARIABLE, + value: provider.id, + type: "Generic", + category: VARIABLE_CATEGORY.SETTINGS, + default_fields: [], + }); + }; + + return { + globalVariables, + isPending, + findVariable, + setVariable, + activateProvider, + }; +} diff --git a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/index.tsx b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/index.tsx index 5804d6047c0e..ec9633f4e1b3 100644 --- a/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/index.tsx +++ b/src/frontend/src/pages/SettingsPage/pages/DBProvidersPage/index.tsx @@ -1,54 +1,23 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import ForwardedIconComponent from "@/components/common/genericIconComponent"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Switch } from "@/components/ui/switch"; import { - ACTIVE_DB_PROVIDER_VARIABLE, - type AvailableDBProviderId, - CHROMA_CLOUD_VARIABLES, DB_PROVIDER_OPTIONS, - type DBProviderBooleanField, - type DBProviderConfigField, type DBProviderId, - type DBProviderOption, type DBProviderTextField, getActiveDBProvider, - getGlobalVariableValue, - OPENSEARCH_VARIABLES, - parseBooleanGlobalVariable, - toAPIBackendType, } from "@/constants/dbProviderConstants"; -import { VARIABLE_CATEGORY } from "@/constants/providerConstants"; -import { useTestDBProviderConnection } from "@/controllers/API/queries/knowledge-bases/use-test-kb-connection"; -import { - useGetGlobalVariables, - usePatchGlobalVariables, - usePostGlobalVariables, -} from "@/controllers/API/queries/variables"; -import useAlertStore from "@/stores/alertStore"; -import type { GlobalVariable } from "@/types/global_variables"; import { cn } from "@/utils/utils"; - -const MASKED_VALUE = "••••••••"; - -type ApiError = { - response?: { - data?: { - detail?: string; - }; - }; -}; - -const getErrorDetail = (error: unknown) => - (error as ApiError)?.response?.data?.detail || - "An unexpected error occurred. Please try again."; +import { ProviderConfigurationPanel } from "./components/ProviderConfigurationPanel"; +import { ProviderListItem } from "./components/ProviderListItem"; +import { useDBProviderActions } from "./hooks/useDBProviderActions"; +import { useDBProviderFields } from "./hooks/useDBProviderFields"; +import { useDBProviderVariables } from "./hooks/useDBProviderVariables"; export default function DBProvidersPage() { const { t } = useTranslation(); - const { data: globalVariables = [] } = useGetGlobalVariables(); + const { globalVariables, isPending, setVariable, activateProvider } = + useDBProviderVariables(); const [selectedProviderId, setSelectedProviderId] = useState( getActiveDBProvider(globalVariables), ); @@ -61,16 +30,6 @@ export default function DBProvidersPage() { {}, ); - const { mutateAsync: createGlobalVariable, isPending: isCreating } = - usePostGlobalVariables(); - const { mutateAsync: updateGlobalVariable, isPending: isUpdating } = - usePatchGlobalVariables(); - const { mutateAsync: testProviderConnection, isPending: isTesting } = - useTestDBProviderConnection(); - - const setSuccessData = useAlertStore((state) => state.setSuccessData); - const setErrorData = useAlertStore((state) => state.setErrorData); - const activeProviderId = useMemo( () => getActiveDBProvider(globalVariables), [globalVariables], @@ -87,300 +46,22 @@ export default function DBProvidersPage() { (provider) => provider.id === selectedProviderId, ) ?? DB_PROVIDER_OPTIONS[0]; - const isPending = isCreating || isUpdating; - - const findVariable = (name: string) => - globalVariables.find((variable) => variable.name === name); - - const setVariable = async ({ - name, - value, - isSecret, - }: { - name: string; - value: string; - isSecret: boolean; - }) => { - const existingVariable = findVariable(name); - if (existingVariable) { - await updateGlobalVariable({ id: existingVariable.id, value }); - return; - } - - await createGlobalVariable({ - name, - value, - type: isSecret ? "Credential" : "Generic", - category: VARIABLE_CATEGORY.GLOBAL, - default_fields: [], - }); - }; - - const activateProvider = async (provider: DBProviderOption) => { - const activeProviderVariable = findVariable(ACTIVE_DB_PROVIDER_VARIABLE); - if (activeProviderVariable) { - await updateGlobalVariable({ - id: activeProviderVariable.id, - value: provider.id, - }); - return; - } - - await createGlobalVariable({ - name: ACTIVE_DB_PROVIDER_VARIABLE, - value: provider.id, - type: "Generic", - category: VARIABLE_CATEGORY.SETTINGS, - default_fields: [], + const { getFieldValue, hasConfiguredValue, isHydrated, canSave } = + useDBProviderFields({ selectedProvider, globalVariables, variableValues }); + + const { handleSave, handleTestConnection, handleUseChroma, isTesting } = + useDBProviderActions({ + selectedProvider, + canSave, + getFieldValue, + variableValues, + setVariable, + activateProvider, + setVariableValues, + setEditingSecret, + setHasManuallySelectedProvider, + setSelectedProviderId, }); - }; - - const getFieldValue = (field: DBProviderConfigField): string => { - if (field.variableKey in variableValues) { - return variableValues[field.variableKey]; - } - if (field.kind === "boolean") { - const stored = parseBooleanGlobalVariable( - globalVariables, - field.variableKey, - field.defaultValue, - ); - return stored ? "true" : "false"; - } - return ( - getGlobalVariableValue(globalVariables, field.variableKey) ?? - field.defaultValue ?? - "" - ); - }; - - const hasConfiguredValue = (variableKey: string) => - globalVariables.some((variable) => variable.name === variableKey); - - // True when every required field already has a saved variable so the - // provider can be activated / tested without the user re-entering anything. - // Secret fields are checked by existence (API responses mask the value); - // non-secret fields are checked by stored value. - const isHydrated = selectedProvider.configFields - .filter( - (field): field is DBProviderTextField => - field.kind !== "boolean" && field.required, - ) - .every((field) => - field.isSecret - ? hasConfiguredValue(field.variableKey) - : Boolean( - getGlobalVariableValue(globalVariables, field.variableKey)?.trim(), - ), - ); - - // Boolean fields always have a defined value (toggle is never blank), - // so they don't gate the save button — only required text fields do. - // Secret fields satisfy the check when the user has typed a new value OR - // the variable already exists (API response masks saved secrets). - const canSave = selectedProvider.configFields - .filter( - (field): field is DBProviderTextField => - field.kind !== "boolean" && field.required, - ) - .every((field) => { - if (field.isSecret) { - return ( - Boolean(variableValues[field.variableKey]?.trim()) || - hasConfiguredValue(field.variableKey) - ); - } - return Boolean(getFieldValue(field).trim()); - }); - - // Returns ``true`` if the save fully succeeded so callers (the Test - // Connection button) can chain a follow-up step. Errors are surfaced - // via toast inside the function — callers should not duplicate them. - // - // ``skipActivation`` lets the Test Connection flow persist credentials - // (so server-side variable_service can resolve them) without switching - // the active provider — testing should never silently change settings. - const handleSave = async (options?: { - silent?: boolean; - skipActivation?: boolean; - }): Promise => { - if (selectedProvider.status !== "available") return false; - if (!canSave) { - setErrorData({ - title: t("settings.dbProviders.errorMissingConfig"), - list: [ - `${selectedProvider.label} requires ${selectedProvider.configFields - .filter( - (field): field is DBProviderTextField => - field.kind !== "boolean" && - field.required && - !getFieldValue(field).trim(), - ) - .map((field) => field.label) - .join(", ")}.`, - ], - }); - return false; - } - - try { - const fieldsToSave = selectedProvider.configFields.filter((field) => { - if (field.kind === "boolean") { - // Persist booleans only when the user actually flipped them - // this session — otherwise we'd write the default to a - // global variable on every save, polluting the variables - // page with values the user never set. - return field.variableKey in variableValues; - } - const nextValue = getFieldValue(field).trim(); - return ( - nextValue && (field.variableKey in variableValues || field.required) - ); - }); - - await Promise.all( - fieldsToSave.map((field) => { - const value = - field.kind === "boolean" - ? getFieldValue(field) // already "true" / "false" - : getFieldValue(field).trim(); - return setVariable({ - name: field.variableKey, - value, - isSecret: field.kind === "boolean" ? false : field.isSecret, - }); - }), - ); - if (!options?.skipActivation) { - await activateProvider(selectedProvider); - // Resetting these on a non-activating save would re-snap the - // selected panel back to the active provider via the - // ``activeProviderId`` useEffect — that's correct after a real - // Save, but wrong when we're only persisting credentials for - // a Test Connection round-trip on a non-active provider. - setVariableValues({}); - setEditingSecret({}); - setHasManuallySelectedProvider(false); - } - if (!options?.silent) { - setSuccessData({ - title: - selectedProvider.id === "chroma" - ? t("settings.dbProviders.chromaSelected") - : t("settings.dbProviders.configSaved", { - provider: selectedProvider.label, - }), - }); - } - return true; - } catch (error: unknown) { - setErrorData({ - title: t("settings.dbProviders.errorSaving"), - list: [getErrorDetail(error)], - }); - return false; - } - }; - - const handleTestConnection = async () => { - if (selectedProvider.status !== "available") return; - if (!canSave) { - setErrorData({ - title: t("settings.dbProviders.errorMissingConfig"), - list: [ - `${selectedProvider.label} requires ${selectedProvider.configFields - .filter( - (field): field is DBProviderTextField => - field.kind !== "boolean" && - field.required && - !getFieldValue(field).trim(), - ) - .map((field) => field.label) - .join(", ")}.`, - ], - }); - return; - } - - // Build the test payload from current form state BEFORE saving — - // ``handleSave`` clears ``variableValues`` on success, so reading - // form values afterward would surface stale globals instead of the - // user's just-saved draft. The backend_config still references - // credential VARIABLE NAMES (URL/USERNAME/PASSWORD), so the - // server-side variable_service lookup happens after Save persists - // them. - const literalFields: Record = {}; - const booleanFields: Record = {}; - for (const field of selectedProvider.configFields) { - const raw = getFieldValue(field); - if (field.kind === "boolean") { - booleanFields[field.variableKey] = raw === "true"; - } else { - literalFields[field.variableKey] = raw.trim(); - } - } - - // Persist the variables first; the server-side test reads - // OPENSEARCH_URL etc. through ``variable_service``. Skip the - // activation step — testing must not switch the active provider. - const saved = await handleSave({ silent: true, skipActivation: true }); - if (!saved) { - // ``handleSave`` already surfaced an error toast. - return; - } - - try { - const backendConfig = buildBackendConfigPayload( - selectedProvider.id as AvailableDBProviderId, - literalFields, - booleanFields, - ); - const response = await testProviderConnection({ - backend_type: toAPIBackendType( - selectedProvider.id as AvailableDBProviderId, - ), - backend_config: backendConfig, - }); - if (response.ok) { - // ``setSuccessData`` only takes a title; pack any backend - // detail (cluster name, version) into the title so it shows. - setSuccessData({ - title: response.message - ? t("settings.dbProviders.connectionSuccessfulWith", { - message: response.message, - }) - : t("settings.dbProviders.connectionSuccessful"), - }); - } else { - setErrorData({ - title: t("settings.dbProviders.connectionFailed"), - list: [ - response.message || t("settings.dbProviders.connectionRejected"), - ], - }); - } - } catch (error: unknown) { - setErrorData({ - title: t("settings.dbProviders.errorTesting"), - list: [getErrorDetail(error)], - }); - } - }; - - const handleUseChroma = async () => { - const chromaProvider = DB_PROVIDER_OPTIONS[0]; - try { - await activateProvider(chromaProvider); - setSelectedProviderId("chroma"); - setHasManuallySelectedProvider(false); - setSuccessData({ title: t("settings.dbProviders.chromaSelected") }); - } catch (error: unknown) { - setErrorData({ - title: t("settings.dbProviders.errorSelectingChroma"), - list: [getErrorDetail(error)], - }); - } - }; return (
@@ -470,397 +151,3 @@ export default function DBProvidersPage() {
); } - -// Build a ``backend_config`` payload for ``POST /test-connection`` from -// the in-memory form values, side-stepping the global-variable cache -// which is stale immediately after Save. The server-side test still -// resolves credentials (URL/USERNAME/PASSWORD) through variable_service -// using the variable-name fields below — those names are stable -// constants and don't depend on what the user typed. -function buildBackendConfigPayload( - providerId: AvailableDBProviderId, - literalFields: Record, - booleanFields: Record, -): Record { - if (providerId === "chroma_cloud") { - return { - mode: "cloud", - tenant_variable: CHROMA_CLOUD_VARIABLES.TENANT, - database_variable: CHROMA_CLOUD_VARIABLES.DATABASE, - api_key_variable: CHROMA_CLOUD_VARIABLES.API_KEY, - cloud_region: literalFields[CHROMA_CLOUD_VARIABLES.REGION] || "us-east-1", - }; - } - if (providerId !== "opensearch") { - return {}; - } - return { - url_variable: OPENSEARCH_VARIABLES.URL, - username_variable: OPENSEARCH_VARIABLES.USERNAME, - password_variable: OPENSEARCH_VARIABLES.PASSWORD, - index_name: literalFields[OPENSEARCH_VARIABLES.INDEX_NAME] || "", - vector_field: - literalFields[OPENSEARCH_VARIABLES.VECTOR_FIELD] || "vector_field", - text_field: literalFields[OPENSEARCH_VARIABLES.TEXT_FIELD] || "text", - use_ssl: booleanFields[OPENSEARCH_VARIABLES.USE_SSL] ?? true, - verify_certs: booleanFields[OPENSEARCH_VARIABLES.VERIFY_CERTS] ?? true, - }; -} - -function ProviderListItem({ - provider, - isActive, - isSelected, - isConfigured, - onSelect, -}: { - provider: DBProviderOption; - isActive: boolean; - isSelected: boolean; - isConfigured: boolean; - onSelect: () => void; -}) { - const { t } = useTranslation(); - const isComingSoon = provider.status === "coming_soon"; - const isMuted = (!isConfigured || isComingSoon) && !provider.defaultEnabled; - - return ( - - ); -} - -function ProviderConfigurationPanel({ - provider, - activeProviderId, - globalVariables, - variableValues, - editingSecret, - isPending, - canSave, - isHydrated, - getFieldValue, - onVariableChange, - onSecretEditingChange, - onSave, - onTestConnection, - isTesting, -}: { - provider: DBProviderOption; - activeProviderId: AvailableDBProviderId; - globalVariables: GlobalVariable[]; - variableValues: Record; - editingSecret: Record; - isPending: boolean; - canSave: boolean; - isHydrated: boolean; - getFieldValue: (field: DBProviderConfigField) => string; - onVariableChange: (key: string, value: string) => void; - onSecretEditingChange: (key: string, editing: boolean) => void; - onSave: () => void; - onTestConnection?: () => void; - isTesting: boolean; -}) { - const { t } = useTranslation(); - const isComingSoon = provider.status === "coming_soon"; - const isActive = activeProviderId === provider.id; - // True when the user has interacted with any field this session. - const hasUnsavedChanges = Object.keys(variableValues).length > 0; - // Label for the primary action button: - // · Active provider → "Save" (persist any edits) - // · Hydrated + no edits → "Use " (just switch active provider) - // · Otherwise → "Save and use " (persist + activate) - const saveButtonLabel = isActive - ? t("settings.dbProviders.save") - : isHydrated && !hasUnsavedChanges - ? t("settings.dbProviders.useProvider", { provider: provider.label }) - : t("settings.dbProviders.saveAndUseProvider", { - provider: provider.label, - }); - - return ( -
-
-
- -
-
- - {provider.label} - - {isActive && !isComingSoon && ( - - {t("settings.dbProviders.active")} - - )} - {isComingSoon && ( - - {t("settings.dbProviders.comingSoon")} - - )} -
- - {t(`settings.dbProviders.providers.${provider.id}.description`, { - defaultValue: provider.description, - })} - -
-
-
- - {isComingSoon ? ( -
- {t("settings.dbProviders.comingSoonDescription")} -
- ) : provider.id === "chroma" ? ( -
-
- {t("settings.dbProviders.chromaDescription")} -
-
- -
-
- ) : ( -
- {provider.configFields.map((field) => - field.kind === "boolean" ? ( - - onVariableChange( - field.variableKey, - checked ? "true" : "false", - ) - } - /> - ) : ( - v.name === field.variableKey) - } - disabled={isPending} - onChange={(value) => onVariableChange(field.variableKey, value)} - onFocus={() => { - // Use variable existence (not its returned value) as the - // gate — credential-type variables may have their value - // masked in the API response. - const isConfigured = - field.isSecret && - globalVariables.some((v) => v.name === field.variableKey); - if (isConfigured && !(field.variableKey in variableValues)) { - onSecretEditingChange(field.variableKey, true); - onVariableChange(field.variableKey, ""); - } - }} - onBlur={() => { - if (!variableValues[field.variableKey]) { - onSecretEditingChange(field.variableKey, false); - } - }} - /> - ), - )} -
- {onTestConnection && ( - - )} - -
-
- )} -
- ); -} - -function TextFieldRow({ - field, - value, - hasNewValue, - isEditingSecret, - existingValue, - isSecretConfigured, - disabled, - onChange, - onFocus, - onBlur, -}: { - field: DBProviderTextField; - value: string; - hasNewValue: boolean; - isEditingSecret: boolean; - existingValue: string | undefined; - isSecretConfigured?: boolean; - disabled: boolean; - onChange: (value: string) => void; - onFocus: () => void; - onBlur: () => void; -}) { - // Show redacted dots when a secret is configured (variable exists) and - // the user is neither actively editing nor has typed a new value this - // session. Use ``isSecretConfigured`` (variable existence) rather than - // ``existingValue`` (returned API value) because credential-type - // variables are not exposed in the global-variables API response. - const { t } = useTranslation(); - const shouldMask = - field.isSecret && - (isSecretConfigured ?? !!existingValue) && - !hasNewValue && - !isEditingSecret; - const inputValue = shouldMask ? MASKED_VALUE : value; - - return ( - - ); -} - -function BooleanFieldRow({ - field, - value, - disabled, - onChange, -}: { - field: DBProviderBooleanField; - value: boolean; - disabled: boolean; - onChange: (checked: boolean) => void; -}) { - const { t } = useTranslation(); - return ( -
-
- - {t(`settings.dbProviders.fields.${field.variableKey}.label`, { - defaultValue: field.label, - })} - - {field.helperText && ( - - {t(`settings.dbProviders.fields.${field.variableKey}.helperText`, { - defaultValue: field.helperText, - })} - - )} - - {t("settings.dbProviders.savedAsGlobalVariable")}{" "} - {field.variableKey} - -
- -
- ); -}