Repository: rilldata/rill
Pull request #6870: OLAP Connectors: Add DSN configuration option
Author: login: ericpgreen2
Created: 2025-03-11T15:34:00Z | Merged: 2025-03-27T19:01:43Z

Closes https://github.com/rilldata/rill/issues/6579

<img width="576" alt="image" src="https://github.com/user-attachments/assets/a1493ffb-1aec-4475-b7d7-8be5c367d60c" />
<img width="576" alt="image" src="https://github.com/user-attachments/assets/2e952bef-52de-464e-8211-07e187eab832" />


**Checklist:**
- [x] Covered by tests
- [x] Ran it and it works as intended
- [x] Reviewed the diff before requesting a review
- [x] Checked for unhandled edge cases
- [x] Linked the issues it closes
- [x] Checked if the docs need to be updated (FYI @royendo for docs updates, please!)
- [ ] Intend to cherry-pick into the release branch
- [x] I'm proud of this work!



Diff:
diff --git a/runtime/drivers/clickhouse/clickhouse.go b/runtime/drivers/clickhouse/clickhouse.go
index 8ba18d75641..405a6236c10 100644
--- a/runtime/drivers/clickhouse/clickhouse.go
+++ b/runtime/drivers/clickhouse/clickhouse.go
@@ -38,7 +38,8 @@ var spec = drivers.Spec{
 			Type:        drivers.StringPropertyType,
 			Required:    false,
 			DisplayName: "Connection string",
-			Placeholder: "clickhouse://localhost:9000?username=default&password=",
+			Placeholder: "clickhouse://localhost:9000?username=default&password=password",
+			Secret:      true,
 			NoPrompt:    true,
 		},
 		{
@@ -81,13 +82,6 @@ var spec = drivers.Spec{
 			DisplayName: "SSL",
 			Description: "Use SSL to connect to the ClickHouse server",
 		},
-		{
-			Key:         "database",
-			Type:        drivers.StringPropertyType,
-			Required:    false,
-			DisplayName: "Database",
-			Description: "Specify the database within the ClickHouse server",
-		},
 	},
 	ImplementsOLAP: true,
 }
diff --git a/web-common/src/features/connectors/ConnectorEntry.svelte b/web-common/src/features/connectors/ConnectorEntry.svelte
index 63cd96fe893..dd29d0cfc5d 100644
--- a/web-common/src/features/connectors/ConnectorEntry.svelte
+++ b/web-common/src/features/connectors/ConnectorEntry.svelte
@@ -28,9 +28,10 @@
 <!-- For now, only show OLAP connectors -->
 {#if implementsOlap}
   {#if connector.name}
-    <li aria-label={connector.name} class="connector-entry">
+    <li class="connector-entry">
       <button
         class="connector-entry-header"
+        aria-label={connector.name}
         on:click={() => {
           store.toggleItem(connectorName);
         }}
diff --git a/web-common/src/features/entity-management/AddAssetButton.svelte b/web-common/src/features/entity-management/AddAssetButton.svelte
index 6535ae2fdb2..73849cdb3d4 100644
--- a/web-common/src/features/entity-management/AddAssetButton.svelte
+++ b/web-common/src/features/entity-management/AddAssetButton.svelte
@@ -172,7 +172,7 @@
   </DropdownMenu.Trigger>
   <DropdownMenu.Content align="start" class="w-[240px]">
     <DropdownMenu.Item
-      aria-label="Add Source"
+      aria-label="Add Data"
       class="flex gap-x-2"
       on:click={handleAddData}
     >
diff --git a/web-common/src/features/sources/modal/AddDataForm.svelte b/web-common/src/features/sources/modal/AddDataForm.svelte
index aca1732d928..4f64adae17d 100644
--- a/web-common/src/features/sources/modal/AddDataForm.svelte
+++ b/web-common/src/features/sources/modal/AddDataForm.svelte
@@ -9,59 +9,77 @@
     type RpcStatus,
     type V1ConnectorDriver,
   } from "@rilldata/web-common/runtime-client";
-  import { defaults, superForm } from "sveltekit-superforms";
+  import type { ActionResult } from "@sveltejs/kit";
+  import { slide } from "svelte/transition";
+  import {
+    defaults,
+    superForm,
+    type SuperValidated,
+  } from "sveltekit-superforms";
   import { yup } from "sveltekit-superforms/adapters";
+  import { ButtonGroup, SubButton } from "../../../components/button-group";
   import { inferSourceName } from "../sourceUtils";
   import { humanReadableErrorMessage } from "./errors";
   import { submitAddDataForm } from "./submitAddDataForm";
   import type { AddDataFormType } from "./types";
-  import { getYupSchema, toYupFriendlyKey } from "./yupSchemas";
+  import { dsnSchema, getYupSchema } from "./yupSchemas";
+
+  const FORM_TRANSITION_DURATION = 150;
 
   export let connector: V1ConnectorDriver;
   export let formType: AddDataFormType;
   export let onBack: () => void;
   export let onClose: () => void;
 
-  $: formId = `add-data-${connector.name}-form`;
-
-  $: isSourceForm = formType === "source";
-  $: isConnectorForm = formType === "connector";
-  $: properties = isConnectorForm
-    ? (connector.configProperties ?? [])
-    : (connector.sourceProperties ?? []);
-
-  let rpcError: RpcStatus | null = null;
+  const isSourceForm = formType === "source";
+  const isConnectorForm = formType === "connector";
 
+  // Form 1: Individual parameters
+  const formId = `add-data-${connector.name}-form`;
+  const properties =
+    (isSourceForm
+      ? connector.sourceProperties
+      : connector.configProperties?.filter(
+          (property) => property.key !== "dsn",
+        )) ?? [];
   const schema = yup(getYupSchema[connector.name as keyof typeof getYupSchema]);
-
   const { form, errors, enhance, tainted, submit, submitting } = superForm(
     defaults(schema),
     {
       SPA: true,
       validators: schema,
-      async onUpdate({ form }) {
-        if (!form.valid) return;
-        const values = form.data;
-        if (isSourceForm) {
-          try {
-            await submitAddDataForm(queryClient, formType, connector, values);
-            onClose();
-          } catch (e) {
-            rpcError = e?.response?.data;
-          }
-          return;
-        }
-
-        // Connectors
-        try {
-          await submitAddDataForm(queryClient, formType, connector, values);
-          onClose();
-        } catch (e) {
-          rpcError = e?.response?.data;
-        }
-      },
+      onUpdate: handleOnUpdate,
     },
   );
+  let rpcError: RpcStatus | null = null;
+
+  // Form 2: DSN
+  // SuperForms are not meant to have dynamic schemas, so we use a different form instance for the DSN form
+  let useDsn = false;
+  const hasDsnFormOption =
+    isConnectorForm &&
+    connector.configProperties?.some((property) => property.key === "dsn");
+  const dsnFormId = `add-data-${connector.name}-dsn-form`;
+  const dsnProperties =
+    connector.configProperties?.filter((property) => property.key === "dsn") ??
+    [];
+  const dsnYupSchema = yup(dsnSchema);
+  const {
+    form: dsnForm,
+    errors: dsnErrors,
+    enhance: dsnEnhance,
+    submit: dsnSubmit,
+    submitting: dsnSubmitting,
+  } = superForm(defaults(dsnYupSchema), {
+    SPA: true,
+    validators: dsnYupSchema,
+    onUpdate: handleOnUpdate,
+  });
+  let dsnRpcError: RpcStatus | null = null;
+
+  function handleConnectionTypeChange(e: CustomEvent<any>): void {
+    useDsn = e.detail === "dsn";
+  }
 
   function onStringInputChange(event: Event) {
     const target = event.target as HTMLInputElement;
@@ -80,57 +98,103 @@
         );
     }
   }
+
+  async function handleOnUpdate<
+    T extends Record<string, unknown>,
+    M = any,
+    In extends Record<string, unknown> = T,
+  >(event: {
+    form: SuperValidated<T, M, In>;
+    formEl: HTMLFormElement;
+    cancel: () => void;
+    result: Extract<ActionResult, { type: "success" | "failure" }>;
+  }) {
+    if (!event.form.valid) return;
+    const values = event.form.data;
+
+    try {
+      await submitAddDataForm(queryClient, formType, connector, values);
+      onClose();
+    } catch (e) {
+      if (useDsn) {
+        dsnRpcError = e?.response?.data;
+      } else {
+        rpcError = e?.response?.data;
+      }
+    }
+  }
 </script>
 
 <div class="h-full w-full flex flex-col">
-  <form
-    class="pb-5 flex-grow overflow-y-auto"
-    id={formId}
-    use:enhance
-    on:submit|preventDefault={submit}
-  >
-    <div class="pb-2 text-slate-500">
-      Need help? Refer to our
-      <a
-        href="https://docs.rilldata.com/build/connect"
-        rel="noreferrer noopener"
-        target="_blank">docs</a
-      > for more information.
+  <div class="pb-2 text-slate-500">
+    Need help? Refer to our
+    <a
+      href="https://docs.rilldata.com/build/connect"
+      rel="noreferrer noopener"
+      target="_blank">docs</a
+    > for more information.
+  </div>
+
+  {#if hasDsnFormOption}
+    <div class="py-3">
+      <div class="text-sm font-medium mb-2">Connection method</div>
+      <ButtonGroup
+        selected={[useDsn ? "dsn" : "parameters"]}
+        on:subbutton-click={handleConnectionTypeChange}
+      >
+        <SubButton value="parameters" ariaLabel="Enter parameters">
+          <span class="px-2">Enter parameters</span>
+        </SubButton>
+        <SubButton value="dsn" ariaLabel="Use connection string">
+          <span class="px-2">Enter connection string</span>
+        </SubButton>
+      </ButtonGroup>
     </div>
-    {#if rpcError}
-      <SubmissionError
-        message={humanReadableErrorMessage(
-          connector.name,
-          rpcError.code,
-          rpcError.message,
-        )}
-      />
-    {/if}
-
-    {#each properties as property (property.key)}
-      {#if property.key !== undefined && !property.noPrompt}
+  {/if}
+
+  {#if !useDsn}
+    <!-- Form 1: Individual parameters -->
+    <form
+      id={formId}
+      class="pb-5 flex-grow overflow-y-auto"
+      use:enhance
+      on:submit|preventDefault={submit}
+      transition:slide={{ duration: FORM_TRANSITION_DURATION }}
+    >
+      {#if rpcError}
+        <SubmissionError
+          message={humanReadableErrorMessage(
+            connector.name,
+            rpcError.code,
+            rpcError.message,
+          )}
+        />
+      {/if}
+
+      {#each properties as property (property.key)}
+        {@const propertyKey = property.key ?? ""}
         {@const label =
           property.displayName + (property.required ? "" : " (optional)")}
         <div class="py-1.5">
           {#if property.type === ConnectorDriverPropertyType.TYPE_STRING || property.type === ConnectorDriverPropertyType.TYPE_NUMBER}
             <Input
-              id={toYupFriendlyKey(property.key)}
+              id={propertyKey}
               label={property.displayName}
               placeholder={property.placeholder}
               optional={!property.required}
               secret={property.secret}
               hint={property.hint}
-              errors={$errors[toYupFriendlyKey(property.key)]}
-              bind:value={$form[toYupFriendlyKey(property.key)]}
+              errors={$errors[propertyKey]}
+              bind:value={$form[propertyKey]}
               onInput={(_, e) => onStringInputChange(e)}
               alwaysShowError
             />
           {:else if property.type === ConnectorDriverPropertyType.TYPE_BOOLEAN}
             <label for={property.key} class="flex items-center">
               <input
-                id={property.key}
+                id={propertyKey}
                 type="checkbox"
-                bind:checked={$form[property.key]}
+                bind:checked={$form[propertyKey]}
                 class="h-5 w-5"
               />
               <span class="ml-2 text-sm">{label}</span>
@@ -143,12 +207,53 @@
             />
           {/if}
         </div>
+      {/each}
+    </form>
+  {:else}
+    <!-- Form 2: DSN -->
+    <form
+      id={dsnFormId}
+      class="pb-5 flex-grow overflow-y-auto"
+      use:dsnEnhance
+      on:submit|preventDefault={dsnSubmit}
+      transition:slide={{ duration: FORM_TRANSITION_DURATION }}
+    >
+      {#if dsnRpcError}
+        <SubmissionError
+          message={humanReadableErrorMessage(
+            connector.name,
+            dsnRpcError.code,
+            dsnRpcError.message,
+          )}
+        />
       {/if}
-    {/each}
-  </form>
+
+      {#each dsnProperties as property (property.key)}
+        {@const propertyKey = property.key ?? ""}
+        <div class="py-1.5">
+          <Input
+            id={propertyKey}
+            label={property.displayName}
+            placeholder={property.placeholder}
+            secret={property.secret}
+            hint={property.hint}
+            errors={$dsnErrors[propertyKey]}
+            bind:value={$dsnForm[propertyKey]}
+            alwaysShowError
+          />
+        </div>
+      {/each}
+    </form>
+  {/if}
+
   <div class="flex items-center space-x-2 ml-auto">
     <Button on:click={onBack} type="secondary">Back</Button>
-    <Button disabled={$submitting} form={formId} submitForm type="primary">
+    <Button
+      disabled={useDsn ? $dsnSubmitting : $submitting}
+      form={useDsn ? dsnFormId : formId}
+      submitForm
+      type="primary"
+    >
       Add data
     </Button>
   </div>
diff --git a/web-common/src/features/sources/modal/submitAddDataForm.ts b/web-common/src/features/sources/modal/submitAddDataForm.ts
index 9336a267713..5386734b75b 100644
--- a/web-common/src/features/sources/modal/submitAddDataForm.ts
+++ b/web-common/src/features/sources/modal/submitAddDataForm.ts
@@ -28,7 +28,6 @@ import { EMPTY_PROJECT_TITLE } from "../../welcome/constants";
 import { isProjectInitialized } from "../../welcome/is-project-initialized";
 import { compileSourceYAML, maybeRewriteToDuckDb } from "../sourceUtils";
 import type { AddDataFormType } from "./types";
-import { fromYupFriendlyKey } from "./yupSchemas";
 
 interface AddDataFormValues {
   // name: string; // Commenting out until we add user-provided names for Connectors
@@ -39,7 +38,7 @@ export async function submitAddDataForm(
   queryClient: QueryClient,
   formType: AddDataFormType,
   connector: V1ConnectorDriver,
-  values: AddDataFormValues,
+  formValues: AddDataFormValues,
 ): Promise<void> {
   const instanceId = get(runtime).instanceId;
 
@@ -64,24 +63,6 @@ export async function submitAddDataForm(
     await invalidate("init");
   }
 
-  // Convert the form values to Source YAML
-  // TODO: Quite a few adhoc code is being added. We should revisit the way we generate the yaml.
-  const formValues = Object.fromEntries(
-    Object.entries(values).map(([key, value]) => {
-      switch (key) {
-        case "project_id":
-        case "account":
-        case "output_location":
-        case "workgroup":
-        case "database_url":
-        case "azure_storage_account":
-          return [key, value];
-        default:
-          return [fromYupFriendlyKey(key), value];
-      }
-    }),
-  );
-
   /**
    * Sources
    */
@@ -94,7 +75,7 @@ export async function submitAddDataForm(
 
     // Make a new <source>.yaml file
     const newSourceFilePath = getFileAPIPathFromNameAndType(
-      values.name as string,
+      formValues.name as string,
       EntityType.Table,
     );
     await runtimeServicePutFile(instanceId, {
diff --git a/web-common/src/features/sources/modal/yupSchemas.ts b/web-common/src/features/sources/modal/yupSchemas.ts
index 5b46a107ef4..cf2b81bb3ae 100644
--- a/web-common/src/features/sources/modal/yupSchemas.ts
+++ b/web-common/src/features/sources/modal/yupSchemas.ts
@@ -223,10 +223,6 @@ export const getYupSchema = {
   }),
 };
 
-export function toYupFriendlyKey(key: string) {
-  return key.replace(/\./g, "_");
-}
-
-export function fromYupFriendlyKey(key: string) {
-  return key.replace(/_/g, ".");
-}
+export const dsnSchema = yup.object().shape({
+  dsn: yup.string().required("DSN is required"),
+});
diff --git a/web-common/src/layout/navigation/Navigation.svelte b/web-common/src/layout/navigation/Navigation.svelte
index 195208e8004..64ed4ba6c5b 100644
--- a/web-common/src/layout/navigation/Navigation.svelte
+++ b/web-common/src/layout/navigation/Navigation.svelte
@@ -10,6 +10,8 @@
 </script>
 
 <script lang="ts">
+  import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
+  import { connectorExplorerStore } from "@rilldata/web-common/features/connectors/connector-explorer-store";
   import { fileArtifacts } from "@rilldata/web-common/features/entity-management/file-artifacts";
   import { writable } from "svelte/store";
   import ConnectorExplorer from "../../features/connectors/ConnectorExplorer.svelte";
@@ -19,8 +21,6 @@
   import { DEFAULT_NAV_WIDTH, MAX_NAV_WIDTH, MIN_NAV_WIDTH } from "../config";
   import Footer from "./Footer.svelte";
   import SurfaceControlButton from "./SurfaceControlButton.svelte";
-  import { connectorExplorerStore } from "@rilldata/web-common/features/connectors/connector-explorer-store";
-  import CaretDownIcon from "@rilldata/web-common/components/icons/CaretDownIcon.svelte";
 
   const DEFAULT_PERCENTAGE = 0.4;
 
@@ -149,6 +149,8 @@
 
             <div
               class="connector-wrapper"
+              role="region"
+              aria-label="Connector explorer"
               bind:this={connectorWrapper}
               style:height="{showConnectors ? connectorSectionHeight : 0}px"
             >
diff --git a/web-local/tests/olap-connectors.spec.ts b/web-local/tests/olap-connectors.spec.ts
new file mode 100644
index 00000000000..1763c4156b1
--- /dev/null
+++ b/web-local/tests/olap-connectors.spec.ts
@@ -0,0 +1,137 @@
+import { expect } from "@playwright/test";
+import { test } from "./setup/base";
+
+/**
+ * These tests focus on form validation and YAML generation for OLAP connectors.
+ * They don't test connections to live OLAP databases - that's handled by runtime integration tests.
+ * Future work: Add tests using ClickHouse testcontainers for end-to-end validation.
+ */
+
+test.describe("ClickHouse connector", () => {
+  test.use({ project: "Blank" });
+
+  test("Create connector using individual fields", async ({ page }) => {
+    // Open the Add Data modal
+    await page.getByRole("button", { name: "Add Asset" }).click();
+    await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+    // Select ClickHouse
+    await page.locator("#clickhouse").click();
+
+    // Verify form validation - empty host
+    await page
+      .getByRole("dialog", { name: "ClickHouse" })
+      .getByRole("button", {
+        name: "Add data",
+      })
+      .click();
+    await expect(page.getByText("Host is required")).toBeVisible();
+
+    // Verify form validation - invalid host with protocol prefix
+    await page.getByRole("textbox", { name: "Host" }).click();
+    await page.getByRole("textbox", { name: "Host" }).fill("http://localhost");
+    await page.getByRole("textbox", { name: "Host" }).press("Tab");
+    await expect(
+      page.getByText("Do not prefix the host with `http(s)://`"),
+    ).toBeVisible();
+
+    // Now, fill in the form correctly
+    await page.getByRole("textbox", { name: "Host" }).fill("localhost");
+    await page.getByRole("textbox", { name: "Host" }).press("Tab");
+    await page.getByRole("textbox", { name: "Port (optional)" }).fill("8123");
+    await page.getByRole("textbox", { name: "Port (optional)" }).press("Tab");
+    await page
+      .getByRole("textbox", { name: "Username (optional)" })
+      .fill("default");
+
+    // Submit the form
+    await page
+      .getByRole("dialog", { name: "ClickHouse" })
+      .getByRole("button", { name: "Add data" })
+      .click();
+
+    // Wait for navigation to the new file
+    await page.waitForURL(`**/files/connectors/clickhouse.yaml`);
+
+    // Assert that the file contains key properties
+    const codeEditor = page
+      .getByLabel("codemirror editor")
+      .getByRole("textbox");
+    await expect(codeEditor).toContainText("type: connector");
+    await expect(codeEditor).toContainText("driver: clickhouse");
+    await expect(codeEditor).toContainText('host: "localhost"');
+    await expect(codeEditor).toContainText("port: 8123");
+    await expect(codeEditor).toContainText('username: "default"');
+
+    // Assert that the connector explorer now has a ClickHouse connector
+    await expect(
+      page
+        .getByRole("region", { name: "Connector explorer" })
+        .getByRole("button", {
+          name: "clickhouse",
+          exact: true,
+        }),
+    ).toBeVisible();
+  });
+
+  test("Create connector using DSN", async ({ page }) => {
+    test.setTimeout(10000);
+    // Open the Add Data modal
+    await page.getByRole("button", { name: "Add Asset" }).click();
+    await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+    // Select ClickHouse
+    await page.locator("#clickhouse").click();
+
+    // Switch to the DSN tab
+    await page.getByRole("button", { name: "Use connection string" }).click();
+
+    // Fill in the form correctly
+    await page
+      .getByRole("textbox", { name: "Connection string" })
+      .fill("http://localhost:8123?username=default&password=password");
+
+    // Submit the form
+    await page
+      .getByRole("dialog", { name: "ClickHouse" })
+      .getByRole("button", { name: "Add data" })
+      .click();
+
+    // Wait for navigation to the new file
+    await page.waitForURL(`**/files/connectors/clickhouse.yaml`);
+
+    // Assert that the file contains key properties
+    const codeEditor = page
+      .getByLabel("codemirror editor")
+      .getByRole("textbox");
+    await expect(codeEditor).toContainText("type: connector");
+    await expect(codeEditor).toContainText("driver: clickhouse");
+    await expect(codeEditor).toContainText(
+      'dsn: "{{ .env.connector.clickhouse.dsn }}"',
+    );
+
+    // Go to the `.env` file and verify the connector.clickhouse.dsn is set
+    await page.getByRole("link", { name: ".env" }).click();
+    const envEditor = page.getByLabel("codemirror editor").getByRole("textbox");
+    await expect(envEditor).toContainText(
+      "connector.clickhouse.dsn=http://localhost:8123?username=default&password=password",
+    );
+
+    // Go to the `rill.yaml` and verify the OLAP connector is set
+    await page.getByRole("link", { name: "rill.yaml" }).click();
+    const rillYamlEditor = page
+      .getByLabel("codemirror editor")
+      .getByRole("textbox");
+    await expect(rillYamlEditor).toContainText("olap_connector: clickhouse");
+
+    // Assert that the connector explorer now has a ClickHouse connector
+    await expect(
+      page
+        .getByRole("region", { name: "Connector explorer" })
+        .getByRole("button", {
+          name: "clickhouse",
+          exact: true,
+        }),
+    ).toBeVisible();
+  });
+});
diff --git a/web-local/tests/utils/sourceHelpers.ts b/web-local/tests/utils/sourceHelpers.ts
index 6f4bf5b4678..2aca6cc5862 100644
--- a/web-local/tests/utils/sourceHelpers.ts
+++ b/web-local/tests/utils/sourceHelpers.ts
@@ -30,7 +30,7 @@ export async function uploadFile(
   // add asset button
   await page.getByLabel("Add Asset").click();
   // add source menu item
-  await page.getByLabel("Add Source").click();
+  await page.getByLabel("Add Data").click();
   // click local file button
   await page.locator("button#local_file").click();
   // wait for file chooser while clicking on upload button


Reviews:
user: login: AdityaHegde
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-24T05:32:03Z
user: login: ericpgreen2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:19:11Z
user: login: ericpgreen2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T13:27:50Z
user: login: AdityaHegde
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-27T14:26:10Z

Review comments:
diff_hunk: @@ -0,0 +1,137 @@
+import { expect } from "@playwright/test";
+import { test } from "./setup/base";
+
+/**
+ * These tests focus on form validation and YAML generation for OLAP connectors.
+ * They don't test connections to live OLAP databases - that's handled by runtime integration tests.
path: web-local/tests/olap-connectors.spec.ts
user: login: AdityaHegde
body: Do we need E2E if we dont actually test the connection? We could have component tests instead?
created_at: 2025-03-24T05:26:18Z
author_association: COLLABORATOR
diff_hunk: @@ -80,57 +98,103 @@
         );
     }
   }
+
+  async function handleOnUpdate<
+    T extends Record<string, unknown>,
+    M = any,
+    In extends Record<string, unknown> = T,
+  >(event: {
+    form: SuperValidated<T, M, In>;
+    formEl: HTMLFormElement;
+    cancel: () => void;
+    result: Extract<ActionResult, { type: "success" | "failure" }>;
+  }) {
+    if (!event.form.valid) return;
+    const values = event.form.data;
+
+    try {
+      await submitAddDataForm(queryClient, formType, connector, values);
+      onClose();
+    } catch (e) {
+      if (useDsn) {
+        dsnRpcError = e?.response?.data;
+      } else {
+        rpcError = e?.response?.data;
+      }
+    }
+  }
 </script>
 
 <div class="h-full w-full flex flex-col">
-  <form
-    class="pb-5 flex-grow overflow-y-auto"
-    id={formId}
-    use:enhance
-    on:submit|preventDefault={submit}
-  >
-    <div class="pb-2 text-slate-500">
-      Need help? Refer to our
-      <a
-        href="https://docs.rilldata.com/build/connect"
-        rel="noreferrer noopener"
-        target="_blank">docs</a
-      > for more information.
+  <div class="pb-2 text-slate-500">
+    Need help? Refer to our
+    <a
+      href="https://docs.rilldata.com/build/connect"
+      rel="noreferrer noopener"
+      target="_blank">docs</a
+    > for more information.
+  </div>
+
+  {#if hasDsnFormOption}
+    <div class="py-3">
+      <div class="text-sm font-medium mb-2">Connection method</div>
+      <ButtonGroup
+        selected={[useDsn ? "dsn" : "parameters"]}
+        on:subbutton-click={handleConnectionTypeChange}
+      >
+        <SubButton value="parameters" ariaLabel="Enter parameters">
+          <span class="px-2">Enter parameters</span>
+        </SubButton>
+        <SubButton value="dsn" ariaLabel="Use connection string">
+          <span class="px-2">Enter connection string</span>
+        </SubButton>
+      </ButtonGroup>
     </div>
-    {#if rpcError}
-      <SubmissionError
-        message={humanReadableErrorMessage(
-          connector.name,
-          rpcError.code,
-          rpcError.message,
-        )}
-      />
-    {/if}
-
-    {#each properties as property (property.key)}
-      {#if property.key !== undefined && !property.noPrompt}
+  {/if}
+
+  {#if !useDsn}
path: web-common/src/features/sources/modal/AddDataForm.svelte
user: login: AdityaHegde
body: Not sure if this is our standard, but it feels weird to have 2 significantly independent form in a single component. Would feel cleaner to me if they were isolated in separate components.
created_at: 2025-03-24T05:31:51Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,137 @@
+import { expect } from "@playwright/test";
+import { test } from "./setup/base";
+
+/**
+ * These tests focus on form validation and YAML generation for OLAP connectors.
+ * They don't test connections to live OLAP databases - that's handled by runtime integration tests.
path: web-local/tests/olap-connectors.spec.ts
user: login: ericpgreen2
body: I added the connection test in this PR: https://github.com/rilldata/rill/pull/6892
created_at: 2025-03-26T13:19:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -80,57 +98,103 @@
         );
     }
   }
+
+  async function handleOnUpdate<
+    T extends Record<string, unknown>,
+    M = any,
+    In extends Record<string, unknown> = T,
+  >(event: {
+    form: SuperValidated<T, M, In>;
+    formEl: HTMLFormElement;
+    cancel: () => void;
+    result: Extract<ActionResult, { type: "success" | "failure" }>;
+  }) {
+    if (!event.form.valid) return;
+    const values = event.form.data;
+
+    try {
+      await submitAddDataForm(queryClient, formType, connector, values);
+      onClose();
+    } catch (e) {
+      if (useDsn) {
+        dsnRpcError = e?.response?.data;
+      } else {
+        rpcError = e?.response?.data;
+      }
+    }
+  }
 </script>
 
 <div class="h-full w-full flex flex-col">
-  <form
-    class="pb-5 flex-grow overflow-y-auto"
-    id={formId}
-    use:enhance
-    on:submit|preventDefault={submit}
-  >
-    <div class="pb-2 text-slate-500">
-      Need help? Refer to our
-      <a
-        href="https://docs.rilldata.com/build/connect"
-        rel="noreferrer noopener"
-        target="_blank">docs</a
-      > for more information.
+  <div class="pb-2 text-slate-500">
+    Need help? Refer to our
+    <a
+      href="https://docs.rilldata.com/build/connect"
+      rel="noreferrer noopener"
+      target="_blank">docs</a
+    > for more information.
+  </div>
+
+  {#if hasDsnFormOption}
+    <div class="py-3">
+      <div class="text-sm font-medium mb-2">Connection method</div>
+      <ButtonGroup
+        selected={[useDsn ? "dsn" : "parameters"]}
+        on:subbutton-click={handleConnectionTypeChange}
+      >
+        <SubButton value="parameters" ariaLabel="Enter parameters">
+          <span class="px-2">Enter parameters</span>
+        </SubButton>
+        <SubButton value="dsn" ariaLabel="Use connection string">
+          <span class="px-2">Enter connection string</span>
+        </SubButton>
+      </ButtonGroup>
     </div>
-    {#if rpcError}
-      <SubmissionError
-        message={humanReadableErrorMessage(
-          connector.name,
-          rpcError.code,
-          rpcError.message,
-        )}
-      />
-    {/if}
-
-    {#each properties as property (property.key)}
-      {#if property.key !== undefined && !property.noPrompt}
+  {/if}
+
+  {#if !useDsn}
path: web-common/src/features/sources/modal/AddDataForm.svelte
user: login: ericpgreen2
body: I agree it's not great, but I was hesitant to split them apart because:
1. We already have the "AddDataModal" wrapping the "AddDataForm". Adding another layer of abstraction would make it harder to follow.
2. The two forms share much in common, like the submission function, structure, and field types.
3. A change that applies to one of these forms likely applies to the other. So this follows "code that changes together lives together".

That being said, splitting them up still might be net-positive. Given there's changes coming to this form flow, I'd propose we keep as is for now and maybe split them up as the modal dialog evolves further.
created_at: 2025-03-26T13:27:50Z
author_association: CONTRIBUTOR

PR comments:
user: login: royendo
created_at: 2025-03-12T14:56:16Z
body: definitely will need to look at revisiting
https://docs.rilldata.com/reference/olap-engines/clickhouse but that seems to be about it :) 
author_association: CONTRIBUTOR
user: login: ericpgreen2
created_at: 2025-03-12T17:18:37Z
body: > definitely will need to look at revisiting https://docs.rilldata.com/reference/olap-engines/clickhouse but that seems to be about it :)

Sounds good – just note that Druid & Pinot will have this DSN option too
author_association: CONTRIBUTOR
user: login: k-anshul
created_at: 2025-03-17T12:59:05Z
body: LGTM from an experience POV.

One minor thing that I noticed(exists on main not introduced in this PR) is that if clickhouse modeling is disabled then I can't add one more clickhouse connection from UI. The `data` option is hidden. I need to manually create another connection.yaml
author_association: MEMBER
user: login: ericpgreen2
created_at: 2025-03-17T15:08:07Z
body: > One minor thing that I noticed(exists on main not introduced in this PR) is that if clickhouse modeling is disabled then I can't add one more clickhouse connection from UI. The `data` option is hidden. I need to manually create another connection.yaml

Thanks, Anshul. Addressed this comment here: https://github.com/rilldata/rill/pull/6904
author_association: CONTRIBUTOR