-
Notifications
You must be signed in to change notification settings - Fork 9.6k
refactor(frontend): decompose DBProvidersPage into components hooks and helpers #14049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: release-1.11.0
Are you sure you want to change the base?
Changes from all commits
9b56a3b
4101abc
6778e1b
db233a8
728cc16
de77bf6
1660fad
5ff7802
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Record<string, unknown>> = []; | ||
| const patchCalls: Array<Record<string, unknown>> = []; | ||
| const mockPostMutateAsync = jest.fn((params: Record<string, unknown>) => { | ||
| postCalls.push(params); | ||
| return Promise.resolve(undefined); | ||
| }); | ||
| const mockPatchMutateAsync = jest.fn((params: Record<string, unknown>) => { | ||
| 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<string, unknown>) => unknown) => | ||
| selector({ | ||
| setSuccessData: mockSetSuccessData, | ||
| setErrorData: mockSetErrorData, | ||
| }), | ||
| })); | ||
|
|
||
| jest.mock("@/components/common/genericIconComponent", () => ({ | ||
| __esModule: true, | ||
| default: ({ name }: { name: string }) => ( | ||
| <span data-testid={`icon-${name}`} /> | ||
| ), | ||
| })); | ||
|
|
||
| const STRINGS: Record<string, string> = { | ||
| "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<string, unknown>) => { | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(<DBProvidersPage />); | ||
| 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(); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className="flex items-start justify-between gap-4 rounded-md border border-border bg-muted/20 px-3 py-2"> | ||
| <div className="flex min-w-0 flex-col"> | ||
| <span className="text-[12px] font-medium"> | ||
| {t(`settings.dbProviders.fields.${field.variableKey}.label`, { | ||
| defaultValue: field.label, | ||
| })} | ||
| </span> | ||
|
Comment on lines
+20
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Line 41 uses the raw 🌐 Proposed fix <Switch
checked={value}
onCheckedChange={onChange}
disabled={disabled}
- aria-label={field.label}
+ aria-label={t(`settings.dbProviders.fields.${field.variableKey}.label`, {
+ defaultValue: field.label,
+ })}
data-testid={`db-provider-toggle-${field.variableKey}`}
/>Also applies to: 41-41 🤖 Prompt for AI Agents |
||
| {field.helperText && ( | ||
| <span className="pt-0.5 text-[11px] text-muted-foreground"> | ||
| {t(`settings.dbProviders.fields.${field.variableKey}.helperText`, { | ||
| defaultValue: field.helperText, | ||
| })} | ||
| </span> | ||
| )} | ||
| <span className="pt-1 text-[11px] text-muted-foreground"> | ||
| {t("settings.dbProviders.savedAsGlobalVariable")}{" "} | ||
| <span className="font-mono">{field.variableKey}</span> | ||
| </span> | ||
| </div> | ||
| <Switch | ||
| checked={value} | ||
| onCheckedChange={onChange} | ||
| disabled={disabled} | ||
| aria-label={field.label} | ||
| data-testid={`db-provider-toggle-${field.variableKey}`} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
No test exercises the error path despite the scaffolding being present.
mockTestMutateAsyncalways resolves successfully andmockSetErrorDatais never asserted as called anywhere in the suite. The coding guideline requires coverage for "positive, negative, edge, and error cases," but this suite only covers happy-path save/activation and disabled-button gating — there's no test for a rejecteduseTestDBProviderConnectioncall, a rejected save, or missing-config error surfacing viasetErrorData.🧪 Example error-path test
As per coding guidelines, "follow frontend test naming conventions... and coverage for positive, negative, edge, and error cases" for frontend test files.
🤖 Prompt for AI Agents
Source: Coding guidelines