diff --git a/package-lock.json b/package-lock.json
index a2476d53e9f..af85c8e0b54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46859,6 +46859,7 @@
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-web": "^1.4.0",
"@dagrejs/dagre": "^1.1.3",
+ "@exodus/schemasafe": "^1.3.0",
"@floating-ui/dom": "^1.7.4",
"@replit/codemirror-indentation-markers": "^6.5.3",
"@storybook/addon-actions": "^7.6.17",
diff --git a/runtime/drivers/azure/azure.go b/runtime/drivers/azure/azure.go
index dbfd51ffd90..9bcd743bef8 100644
--- a/runtime/drivers/azure/azure.go
+++ b/runtime/drivers/azure/azure.go
@@ -39,12 +39,14 @@ var spec = drivers.Spec{
Secret: true,
},
{
- Key: "azure_storage_connection_string",
- Type: drivers.StringPropertyType,
- Secret: true,
+ Key: "azure_storage_connection_string",
+ Type: drivers.StringPropertyType,
+ DisplayName: "Azure Connection String",
+ Description: "Azure connection string for storage account",
+ Placeholder: "Paste your Azure connection string here",
+ Secret: true,
},
},
- // Important: Any edits to the below properties must be accompanied by changes to the client-side form validation schemas.
SourceProperties: []*drivers.PropertySpec{
{
Key: "path",
diff --git a/runtime/drivers/s3/s3.go b/runtime/drivers/s3/s3.go
index 90e84dfd0ca..005e7ab4868 100644
--- a/runtime/drivers/s3/s3.go
+++ b/runtime/drivers/s3/s3.go
@@ -26,14 +26,22 @@ var spec = drivers.Spec{
DocsURL: "https://docs.rilldata.com/build/connectors/data-source/s3",
ConfigProperties: []*drivers.PropertySpec{
{
- Key: "aws_access_key_id",
- Type: drivers.StringPropertyType,
- Secret: true,
+ Key: "aws_access_key_id",
+ Type: drivers.StringPropertyType,
+ DisplayName: "AWS access key ID",
+ Description: "AWS access key ID for explicit credentials",
+ Placeholder: "Enter your AWS access key ID",
+ Secret: true,
+ Required: true,
},
{
- Key: "aws_secret_access_key",
- Type: drivers.StringPropertyType,
- Secret: true,
+ Key: "aws_secret_access_key",
+ Type: drivers.StringPropertyType,
+ DisplayName: "AWS secret access key",
+ Description: "AWS secret access key for explicit credentials",
+ Placeholder: "Enter your AWS secret access key",
+ Secret: true,
+ Required: true,
},
{
Key: "region",
diff --git a/web-common/package.json b/web-common/package.json
index a34c03c267c..c1dfd35cc60 100644
--- a/web-common/package.json
+++ b/web-common/package.json
@@ -19,7 +19,6 @@
},
"devDependencies": {
"@bufbuild/protobuf": "^1.0.0",
- "@dagrejs/dagre": "^1.1.3",
"@codemirror/autocomplete": "^6.18.1",
"@codemirror/commands": "^6.7.0",
"@codemirror/lang-json": "^6.0.1",
@@ -34,6 +33,8 @@
"@codemirror/view": "^6.34.1",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-web": "^1.4.0",
+ "@dagrejs/dagre": "^1.1.3",
+ "@exodus/schemasafe": "^1.3.0",
"@floating-ui/dom": "^1.7.4",
"@replit/codemirror-indentation-markers": "^6.5.3",
"@storybook/addon-actions": "^7.6.17",
@@ -64,6 +65,7 @@
"@types/dompurify": "^3.0.5",
"@types/luxon": "^3.4.2",
"@types/memoizee": "^0.4.11",
+ "@xyflow/svelte": "^0.1.39",
"autoprefixer": "^10.4.20",
"bits-ui": "^0.22.0",
"chroma-js": "^3.1.2",
@@ -114,7 +116,6 @@
"vega-lite": "^5.23.0",
"vega-typings": "^1.3.1",
"vitest": "^3.1.1",
- "@xyflow/svelte": "^0.1.39",
"yaml": "^2.4.5",
"yup": "^1.4.0"
},
diff --git a/web-common/src/features/entity-management/name-utils.ts b/web-common/src/features/entity-management/name-utils.ts
index 01f9567a937..52a6d72dc99 100644
--- a/web-common/src/features/entity-management/name-utils.ts
+++ b/web-common/src/features/entity-management/name-utils.ts
@@ -21,8 +21,22 @@ export function getName(name: string, others: string[]): string {
const set = new Set(others.map((other) => other.toLowerCase()));
let result = name;
+ const incrementableSuffix = /(.+)_([0-9]+)$/;
while (set.has(result.toLowerCase())) {
+ // Special-case for "s3": don't roll over to "s4", append suffix instead.
+ if (name.toLowerCase() === "s3") {
+ const match = incrementableSuffix.exec(result);
+ if (match) {
+ const base = match[1];
+ const number = Number.parseInt(match[2], 10) + 1;
+ result = `${base}_${number}`;
+ continue;
+ }
+ result = `${name}_1`;
+ continue;
+ }
+
result = INCREMENT.exec(result)?.[1]
? result.replace(INCREMENT, (m) => (+m + 1).toString())
: `${result}_1`;
diff --git a/web-common/src/features/sources/modal/AddClickHouseForm.svelte b/web-common/src/features/sources/modal/AddClickHouseForm.svelte
deleted file mode 100644
index 3248cf7487d..00000000000
--- a/web-common/src/features/sources/modal/AddClickHouseForm.svelte
+++ /dev/null
@@ -1,506 +0,0 @@
-
-
-
-
-
- {#if connectorType === "rill-managed"}
-
-
-
- {/if}
-
-
- {#if connectorType === "self-hosted" || connectorType === "clickhouse-cloud"}
-
-
-
-
-
-
-
-
- {:else}
-
-
- {/if}
-
diff --git a/web-common/src/features/sources/modal/AddDataForm.svelte b/web-common/src/features/sources/modal/AddDataForm.svelte
index ee081dc8f34..1ae60780bf2 100644
--- a/web-common/src/features/sources/modal/AddDataForm.svelte
+++ b/web-common/src/features/sources/modal/AddDataForm.svelte
@@ -8,24 +8,27 @@
import type { SuperValidated } from "sveltekit-superforms";
import type { AddDataFormType, ConnectorType } from "./types";
- import AddClickHouseForm from "./AddClickHouseForm.svelte";
+ import MultiStepConnectorFlow from "./MultiStepConnectorFlow.svelte";
import NeedHelpText from "./NeedHelpText.svelte";
import Tabs from "@rilldata/web-common/components/forms/Tabs.svelte";
import { TabsContent } from "@rilldata/web-common/components/tabs";
- import { isEmpty } from "./utils";
+ import { hasOnlyDsn, isEmpty } from "./utils";
+ import JSONSchemaFormRenderer from "../../templates/JSONSchemaFormRenderer.svelte";
import {
CONNECTION_TAB_OPTIONS,
type ClickHouseConnectorType,
} from "./constants";
- import { getInitialFormValuesFromProperties } from "../sourceUtils";
-
import { connectorStepStore } from "./connectorStepStore";
import FormRenderer from "./FormRenderer.svelte";
import YamlPreview from "./YamlPreview.svelte";
- import GCSMultiStepForm from "./GCSMultiStepForm.svelte";
- import { AddDataFormManager } from "./AddDataFormManager";
- import { hasOnlyDsn } from "./utils";
+ import {
+ AddDataFormManager,
+ type ClickhouseUiState,
+ } from "./AddDataFormManager";
+ import ClickhouseFormRenderer from "./ClickhouseFormRenderer.svelte";
import AddDataFormSection from "./AddDataFormSection.svelte";
+ import { get, type Writable } from "svelte/store";
+ import { getConnectorSchema } from "./connector-schemas";
export let connector: V1ConnectorDriver;
export let formType: AddDataFormType;
@@ -54,45 +57,31 @@
formType,
onParamsUpdate: (e: any) => handleOnUpdate(e),
onDsnUpdate: (e: any) => handleOnUpdate(e),
+ getSelectedAuthMethod: () =>
+ get(connectorStepStore).selectedAuthMethod ?? undefined,
});
const isMultiStepConnector = formManager.isMultiStepConnector;
const isSourceForm = formManager.isSourceForm;
const isConnectorForm = formManager.isConnectorForm;
const onlyDsn = hasOnlyDsn(connector, isConnectorForm);
- $: stepState = $connectorStepStore;
- $: stepProperties =
- isMultiStepConnector && stepState.step === "source"
- ? (connector.sourceProperties ?? [])
- : properties;
- $: if (
- isMultiStepConnector &&
- stepState.step === "source" &&
- stepState.connectorConfig
- ) {
- // Initialize form with source properties and default values
- const sourceProperties = connector.sourceProperties ?? [];
- const initialValues = getInitialFormValuesFromProperties(sourceProperties);
-
- // Merge with stored connector config
- const combinedValues = { ...stepState.connectorConfig, ...initialValues };
-
- paramsForm.update(() => combinedValues, { taint: false });
- }
+ let activeAuthMethod: string | null = null;
+ let prevAuthMethod: string | null = null;
+ let stepState = $connectorStepStore;
+ let multiStepSubmitDisabled = false;
+ let multiStepButtonLabel = "";
+ let multiStepLoadingCopy = "";
+ let shouldShowSkipLink = false;
+ let primaryButtonLabel = "";
+ let primaryLoadingCopy = "";
- // Update form when (re)entering step 1: restore defaults for connector properties
- $: if (isMultiStepConnector && stepState.step === "connector") {
- paramsForm.update(
- () =>
- getInitialFormValuesFromProperties(connector.configProperties ?? []),
- { taint: false },
- );
- }
+ $: stepState = $connectorStepStore;
// Form 1: Individual parameters
const paramsFormId = formManager.paramsFormId;
const properties = formManager.properties;
const filteredParamsProperties = formManager.filteredParamsProperties;
+ let multiStepFormId = paramsFormId;
const {
form: paramsForm,
errors: paramsErrors,
@@ -123,15 +112,63 @@
let clickhouseError: string | null = null;
let clickhouseErrorDetails: string | undefined = undefined;
- let clickhouseFormId: string = "";
- let clickhouseSubmitting: boolean;
- let clickhouseIsSubmitDisabled: boolean;
let clickhouseConnectorType: ClickHouseConnectorType = "self-hosted";
- let clickhouseParamsForm;
- let clickhouseDsnForm;
- let clickhouseShowSaveAnyway: boolean = false;
+ let clickhouseUiState: ClickhouseUiState | null = null;
+ let clickhouseSaving = false;
+ let effectiveClickhouseSubmitting = false;
+ const paramsFormStore = paramsForm as unknown as Writable<
+ Record
+ >;
+ const dsnFormStore = dsnForm as unknown as Writable>;
+
+ const connectorSchema = getConnectorSchema(connector.name ?? "");
+ const hasSchema = Boolean(connectorSchema);
+
+ // ClickHouse-specific derived state handled by the manager
+ $: if (connector.name === "clickhouse") {
+ clickhouseUiState = formManager.computeClickhouseState({
+ connectorType: clickhouseConnectorType,
+ connectionTab,
+ paramsFormValues: $paramsForm,
+ dsnFormValues: $dsnForm,
+ paramsErrors: $paramsErrors,
+ dsnErrors: $dsnErrors,
+ paramsForm,
+ dsnForm,
+ paramsSubmitting: $paramsSubmitting,
+ dsnSubmitting: $dsnSubmitting,
+ });
+
+ if (
+ clickhouseUiState?.enforcedConnectionTab &&
+ clickhouseUiState.enforcedConnectionTab !== connectionTab
+ ) {
+ connectionTab = clickhouseUiState.enforcedConnectionTab;
+ }
+
+ if (clickhouseUiState?.shouldClearErrors) {
+ clickhouseError = null;
+ clickhouseErrorDetails = undefined;
+ }
+ } else {
+ clickhouseUiState = null;
+ }
+
+ $: effectiveClickhouseSubmitting =
+ connector.name === "clickhouse"
+ ? clickhouseSaving || clickhouseUiState?.submitting || false
+ : submitting;
+
+ // Hide Save Anyway once we advance to the model step in multi-step flows.
+ $: if (isMultiStepConnector && stepState.step === "source") {
+ showSaveAnyway = false;
+ }
$: isSubmitDisabled = (() => {
+ if (isMultiStepConnector) {
+ return multiStepSubmitDisabled;
+ }
+
if (onlyDsn || connectionTab === "dsn") {
// DSN form: check required DSN properties
for (const property of dsnProperties) {
@@ -149,12 +186,7 @@
return false;
} else {
// Parameters form: check required properties
- // Use stepProperties for multi-step connectors, otherwise use properties
- const propertiesToCheck = isMultiStepConnector
- ? stepProperties
- : properties;
-
- for (const property of propertiesToCheck) {
+ for (const property of properties) {
if (property.required) {
const key = String(property.key);
const value = $paramsForm[key];
@@ -168,7 +200,9 @@
}
})();
- $: formId = formManager.getActiveFormId({ connectionTab, onlyDsn });
+ $: formId = isMultiStepConnector
+ ? multiStepFormId || formManager.getActiveFormId({ connectionTab, onlyDsn })
+ : formManager.getActiveFormId({ connectionTab, onlyDsn });
$: submitting = (() => {
if (onlyDsn || connectionTab === "dsn") {
@@ -178,7 +212,36 @@
}
})();
- $: isSubmitting = submitting;
+ $: primaryButtonLabel = isMultiStepConnector
+ ? multiStepButtonLabel
+ : formManager.getPrimaryButtonLabel({
+ isConnectorForm,
+ step: stepState.step,
+ submitting,
+ clickhouseConnectorType,
+ clickhouseSubmitting: effectiveClickhouseSubmitting,
+ selectedAuthMethod: activeAuthMethod ?? undefined,
+ });
+
+ $: primaryLoadingCopy = (() => {
+ if (connector.name === "clickhouse") return "Connecting...";
+ if (isMultiStepConnector) return multiStepLoadingCopy;
+ return activeAuthMethod === "public"
+ ? "Continuing..."
+ : "Testing connection...";
+ })();
+
+ // Clear Save Anyway state whenever auth method changes (any direction).
+ $: if (activeAuthMethod !== prevAuthMethod) {
+ prevAuthMethod = activeAuthMethod;
+ showSaveAnyway = false;
+ saveAnyway = false;
+ }
+
+ $: isSubmitting =
+ connector.name === "clickhouse"
+ ? effectiveClickhouseSubmitting
+ : submitting;
// Reset errors when form is modified
$: (() => {
@@ -213,13 +276,13 @@
const values =
connector.name === "clickhouse"
? connectionTab === "dsn"
- ? $clickhouseDsnForm
- : $clickhouseParamsForm
+ ? $dsnForm
+ : $paramsForm
: onlyDsn || connectionTab === "dsn"
? $dsnForm
: $paramsForm;
if (connector.name === "clickhouse") {
- clickhouseSubmitting = true;
+ clickhouseSaving = true;
}
const result = await formManager.saveConnectorAnyway({
queryClient,
@@ -247,7 +310,7 @@
}
saveAnyway = false;
if (connector.name === "clickhouse") {
- clickhouseSubmitting = false;
+ clickhouseSaving = false;
}
}
@@ -262,20 +325,20 @@
paramsFormValues: $paramsForm,
dsnFormValues: $dsnForm,
clickhouseConnectorType,
- clickhouseParamsValues: $clickhouseParamsForm,
- clickhouseDsnValues: $clickhouseDsnForm,
+ clickhouseParamsValues: $paramsForm,
+ clickhouseDsnValues: $dsnForm,
});
$: isClickhouse = connector.name === "clickhouse";
- $: shouldShowSaveAnywayButton =
- isConnectorForm && (showSaveAnyway || clickhouseShowSaveAnyway);
+ $: shouldShowSaveAnywayButton = isConnectorForm && showSaveAnyway;
$: saveAnywayLoading = isClickhouse
- ? clickhouseSubmitting && saveAnyway
+ ? effectiveClickhouseSubmitting && saveAnyway
: submitting && saveAnyway;
handleOnUpdate = formManager.makeOnUpdate({
onClose,
queryClient,
getConnectionTab: () => connectionTab,
+ getSelectedAuthMethod: () => activeAuthMethod || undefined,
setParamsError: (message: string | null, details?: string) => {
paramsError = message;
paramsErrorDetails = details;
@@ -310,21 +373,21 @@
class="flex flex-col flex-grow {formManager.formHeight} overflow-y-auto p-6"
>
{#if connector.name === "clickhouse"}
- {
- clickhouseError = error;
- clickhouseErrorDetails = details;
- }}
- bind:formId={clickhouseFormId}
- bind:isSubmitting={clickhouseSubmitting}
- bind:isSubmitDisabled={clickhouseIsSubmitDisabled}
- bind:connectorType={clickhouseConnectorType}
+
{:else if hasDsnFormOption}
{:else if isMultiStepConnector}
- {#if stepState.step === "connector"}
-
-
-
-
- {:else}
-
-
-
-
- {/if}
+
+ {:else if hasSchema}
+
+
+
{:else}
{/if}
- {#if isMultiStepConnector && stepState.step === "connector"}
-
- {/if}
-
@@ -482,31 +536,45 @@
- {#if dsnError || paramsError || clickhouseError}
-
+ {#if dsnError || paramsError || clickhouseError}
+
+ {/if}
+
+
- {/if}
-
-
+
+ {#if shouldShowSkipLink}
+
+ Already connected?
+
+ {/if}
+
diff --git a/web-common/src/features/sources/modal/AddDataFormManager.ts b/web-common/src/features/sources/modal/AddDataFormManager.ts
index e2d0f9c4c10..b3b9fd10565 100644
--- a/web-common/src/features/sources/modal/AddDataFormManager.ts
+++ b/web-common/src/features/sources/modal/AddDataFormManager.ts
@@ -1,13 +1,14 @@
import { superForm, defaults } from "sveltekit-superforms";
import type { SuperValidated } from "sveltekit-superforms";
+import * as yupLib from "yup";
import {
- yup,
+ yup as yupAdapter,
type Infer as YupInfer,
type InferIn as YupInferIn,
} from "sveltekit-superforms/adapters";
import type { V1ConnectorDriver } from "@rilldata/web-common/runtime-client";
import type { AddDataFormType } from "./types";
-import { getValidationSchemaForConnector, dsnSchema } from "./FormValidation";
+import { getValidationSchemaForConnector } from "./FormValidation";
import {
getInitialFormValuesFromProperties,
inferSourceName,
@@ -16,7 +17,11 @@ import {
submitAddConnectorForm,
submitAddSourceForm,
} from "./submitAddDataForm";
-import { normalizeConnectorError } from "./utils";
+import {
+ normalizeConnectorError,
+ applyClickHouseCloudRequirements,
+ isEmpty,
+} from "./utils";
import {
FORM_HEIGHT_DEFAULT,
FORM_HEIGHT_TALL,
@@ -27,24 +32,61 @@ import {
connectorStepStore,
setConnectorConfig,
setStep,
+ type ConnectorStepState,
} from "./connectorStepStore";
import { get } from "svelte/store";
import { compileConnectorYAML } from "../../connectors/code-utils";
import { compileSourceYAML, prepareSourceFormData } from "../sourceUtils";
import type { ConnectorDriverProperty } from "@rilldata/web-common/runtime-client";
import type { ClickHouseConnectorType } from "./constants";
-import { applyClickHouseCloudRequirements } from "./utils";
import type { ActionResult } from "@sveltejs/kit";
+import { getConnectorSchema } from "./connector-schemas";
+import { findRadioEnumKey } from "../../templates/schema-utils";
+
+const dsnSchema = yupLib.object({
+ dsn: yupLib.string().required("DSN is required"),
+});
+
+export type ClickhouseUiState = {
+ properties: ConnectorDriverProperty[];
+ filteredProperties: ConnectorDriverProperty[];
+ dsnProperties: ConnectorDriverProperty[];
+ isSubmitDisabled: boolean;
+ formId: string;
+ submitting: boolean;
+ enforcedConnectionTab?: "parameters" | "dsn";
+ shouldClearErrors?: boolean;
+};
+
+type SuperFormStore = {
+ update: (
+ updater: (value: Record) => Record,
+ options?: any,
+ ) => void;
+};
+
+type ClickhouseStateArgs = {
+ connectorType: ClickHouseConnectorType;
+ connectionTab: "parameters" | "dsn";
+ paramsFormValues: Record;
+ dsnFormValues: Record;
+ paramsErrors: Record;
+ dsnErrors: Record;
+ paramsForm: SuperFormStore;
+ dsnForm: SuperFormStore;
+ paramsSubmitting: boolean;
+ dsnSubmitting: boolean;
+};
// Minimal onUpdate event type carrying Superforms's validated form
type SuperFormUpdateEvent = {
form: SuperValidated, any, Record>;
};
-// Shape of the step store for multi-step connectors
-type ConnectorStepState = {
- step: "connector" | "source";
- connectorConfig: Record | null;
+const BUTTON_LABELS = {
+ public: { idle: "Continue", submitting: "Continuing..." },
+ connector: { idle: "Test and Connect", submitting: "Testing connection..." },
+ source: { idle: "Import Data", submitting: "Importing data..." },
};
export class AddDataFormManager {
@@ -63,21 +105,33 @@ export class AddDataFormManager {
dsn: ReturnType;
private connector: V1ConnectorDriver;
private formType: AddDataFormType;
+ private clickhouseInitialValues: Record;
+ private clickhousePrevConnectorType?: ClickHouseConnectorType;
// Centralized error normalization for this manager
private normalizeError(e: unknown): { message: string; details?: string } {
return normalizeConnectorError(this.connector.name ?? "", e);
}
+ private getSelectedAuthMethod?: () => string | undefined;
+
constructor(args: {
connector: V1ConnectorDriver;
formType: AddDataFormType;
onParamsUpdate: (event: SuperFormUpdateEvent) => void;
onDsnUpdate: (event: SuperFormUpdateEvent) => void;
+ getSelectedAuthMethod?: () => string | undefined;
}) {
- const { connector, formType, onParamsUpdate, onDsnUpdate } = args;
+ const {
+ connector,
+ formType,
+ onParamsUpdate,
+ onDsnUpdate,
+ getSelectedAuthMethod,
+ } = args;
this.connector = connector;
this.formType = formType;
+ this.getSelectedAuthMethod = getSelectedAuthMethod;
// Layout height
this.formHeight = TALL_FORM_CONNECTORS.has(connector.name ?? "")
@@ -125,12 +179,12 @@ export class AddDataFormManager {
);
// Superforms: params
- const paramsSchemaDef = getValidationSchemaForConnector(
+ const paramsAdapter = getValidationSchemaForConnector(
connector.name as string,
+ formType,
);
- const paramsAdapter = yup(paramsSchemaDef);
- type ParamsOut = YupInfer;
- type ParamsIn = YupInferIn;
+ type ParamsOut = Record;
+ type ParamsIn = Record;
const initialFormValues = getInitialFormValuesFromProperties(
this.properties,
);
@@ -143,10 +197,11 @@ export class AddDataFormManager {
validators: paramsAdapter,
onUpdate: onParamsUpdate,
resetForm: false,
+ validationMethod: "onsubmit",
});
// Superforms: dsn
- const dsnAdapter = yup(dsnSchema);
+ const dsnAdapter = yupAdapter(dsnSchema);
type DsnOut = YupInfer;
type DsnIn = YupInferIn;
this.dsn = superForm(defaults(dsnAdapter), {
@@ -154,7 +209,18 @@ export class AddDataFormManager {
validators: dsnAdapter,
onUpdate: onDsnUpdate,
resetForm: false,
+ validationMethod: "onsubmit",
});
+
+ // ClickHouse-specific defaults
+ this.clickhouseInitialValues =
+ connector.name === "clickhouse"
+ ? getInitialFormValuesFromProperties(connector.configProperties ?? [])
+ : {};
+ this.clickhousePrevConnectorType =
+ connector.name === "clickhouse"
+ ? ("self-hosted" as ClickHouseConnectorType)
+ : undefined;
}
get isSourceForm(): boolean {
@@ -169,6 +235,37 @@ export class AddDataFormManager {
return MULTI_STEP_CONNECTORS.includes(this.connector.name ?? "");
}
+ /**
+ * Determines whether the "Save Anyway" button should be shown for the current submission.
+ */
+ private shouldShowSaveAnywayButton(args: {
+ isConnectorForm: boolean;
+ event?:
+ | {
+ result?: Extract;
+ }
+ | undefined;
+ stepState: ConnectorStepState | undefined;
+ selectedAuthMethod?: string;
+ }): boolean {
+ const { isConnectorForm, event, stepState, selectedAuthMethod } = args;
+
+ // Only show for connector forms (not sources)
+ if (!isConnectorForm) return false;
+
+ // Need a submission result to show the button
+ if (!event?.result) return false;
+
+ // Multi-step connectors: don't show on source step (final step)
+ if (stepState?.step === "source") return false;
+
+ // Public auth bypasses connection test, so no "Save Anyway" needed
+ if (stepState?.step === "connector" && selectedAuthMethod === "public")
+ return false;
+
+ return true;
+ }
+
getActiveFormId(args: {
connectionTab: "parameters" | "dsn";
onlyDsn: boolean;
@@ -182,7 +279,7 @@ export class AddDataFormManager {
handleSkip(): void {
const stepState = get(connectorStepStore) as ConnectorStepState;
if (!this.isMultiStepConnector || stepState.step !== "connector") return;
- setConnectorConfig(get(this.params.form) as Record);
+ setConnectorConfig({});
setStep("source");
}
@@ -201,6 +298,7 @@ export class AddDataFormManager {
submitting: boolean;
clickhouseConnectorType?: ClickHouseConnectorType;
clickhouseSubmitting?: boolean;
+ selectedAuthMethod?: string;
}): string {
const {
isConnectorForm,
@@ -208,6 +306,7 @@ export class AddDataFormManager {
submitting,
clickhouseConnectorType,
clickhouseSubmitting,
+ selectedAuthMethod,
} = args;
const isClickhouse = this.connector.name === "clickhouse";
@@ -222,21 +321,172 @@ export class AddDataFormManager {
if (isConnectorForm) {
if (this.isMultiStepConnector && step === "connector") {
- return submitting ? "Testing connection..." : "Test and Connect";
+ if (selectedAuthMethod === "public") {
+ return submitting
+ ? BUTTON_LABELS.public.submitting
+ : BUTTON_LABELS.public.idle;
+ }
+ return submitting
+ ? BUTTON_LABELS.connector.submitting
+ : BUTTON_LABELS.connector.idle;
}
if (this.isMultiStepConnector && step === "source") {
- return submitting ? "Creating model..." : "Test and Add data";
+ return submitting
+ ? BUTTON_LABELS.source.submitting
+ : BUTTON_LABELS.source.idle;
}
- return submitting ? "Testing connection..." : "Test and Connect";
+ return submitting
+ ? BUTTON_LABELS.connector.submitting
+ : BUTTON_LABELS.connector.idle;
}
return "Test and Add data";
}
+ computeClickhouseState(args: ClickhouseStateArgs): ClickhouseUiState | null {
+ if (this.connector.name !== "clickhouse") return null;
+ const {
+ connectorType,
+ connectionTab,
+ paramsFormValues,
+ dsnFormValues,
+ paramsErrors,
+ dsnErrors,
+ paramsForm,
+ paramsSubmitting,
+ dsnSubmitting,
+ } = args;
+
+ // Keep connector_type in sync on the params form
+ paramsForm.update(
+ ($form: any) => ({
+ ...$form,
+ connector_type: connectorType,
+ }),
+ { taint: false } as any,
+ );
+
+ // Apply defaults when the ClickHouse connector type changes
+ if (
+ connectorType === "rill-managed" &&
+ Object.keys(paramsFormValues ?? {}).length > 1
+ ) {
+ paramsForm.update(
+ () => ({ managed: true, connector_type: "rill-managed" }),
+ { taint: false } as any,
+ );
+ } else if (
+ this.clickhousePrevConnectorType === "rill-managed" &&
+ connectorType === "self-hosted"
+ ) {
+ paramsForm.update(
+ () => ({ ...this.clickhouseInitialValues, managed: false }),
+ { taint: false } as any,
+ );
+ } else if (
+ this.clickhousePrevConnectorType !== "clickhouse-cloud" &&
+ connectorType === "clickhouse-cloud"
+ ) {
+ paramsForm.update(
+ () => ({
+ ...this.clickhouseInitialValues,
+ managed: false,
+ port: "8443",
+ ssl: true,
+ }),
+ { taint: false } as any,
+ );
+ } else if (
+ this.clickhousePrevConnectorType === "clickhouse-cloud" &&
+ connectorType === "self-hosted"
+ ) {
+ paramsForm.update(
+ () => ({ ...this.clickhouseInitialValues, managed: false }),
+ { taint: false } as any,
+ );
+ }
+ this.clickhousePrevConnectorType = connectorType;
+
+ const enforcedConnectionTab =
+ connectorType === "rill-managed" ? ("parameters" as const) : undefined;
+ const activeConnectionTab = enforcedConnectionTab ?? connectionTab;
+
+ const properties =
+ connectorType === "rill-managed"
+ ? (this.connector.sourceProperties ?? [])
+ : (this.connector.configProperties ?? []);
+
+ const filteredProperties = properties.filter(
+ (property) =>
+ !property.noPrompt &&
+ property.key !== "managed" &&
+ (activeConnectionTab !== "dsn" ? property.key !== "dsn" : true),
+ );
+
+ const dsnProperties =
+ this.connector.configProperties?.filter(
+ (property) => property.key === "dsn",
+ ) ?? [];
+
+ const isSubmitDisabled = (() => {
+ if (connectorType === "rill-managed") {
+ for (const property of filteredProperties) {
+ if (property.required) {
+ const key = String(property.key);
+ const value = paramsFormValues?.[key];
+ if (isEmpty(value) || (paramsErrors?.[key] as any)?.length)
+ return true;
+ }
+ }
+ return false;
+ }
+ if (activeConnectionTab === "dsn") {
+ for (const property of dsnProperties) {
+ if (property.required) {
+ const key = String(property.key);
+ const value = dsnFormValues?.[key];
+ if (isEmpty(value) || (dsnErrors?.[key] as any)?.length)
+ return true;
+ }
+ }
+ return false;
+ }
+
+ for (const property of filteredProperties) {
+ if (property.required && property.key !== "managed") {
+ const key = String(property.key);
+ const value = paramsFormValues?.[key];
+ if (isEmpty(value) || (paramsErrors?.[key] as any)?.length)
+ return true;
+ }
+ }
+ if (connectorType === "clickhouse-cloud" && !paramsFormValues?.ssl)
+ return true;
+ return false;
+ })();
+
+ const submitting =
+ activeConnectionTab === "dsn" ? dsnSubmitting : paramsSubmitting;
+ const formId =
+ activeConnectionTab === "dsn" ? this.dsnFormId : this.paramsFormId;
+
+ return {
+ properties,
+ filteredProperties,
+ dsnProperties,
+ isSubmitDisabled,
+ formId,
+ submitting,
+ enforcedConnectionTab,
+ shouldClearErrors: connectorType === "rill-managed",
+ };
+ }
+
makeOnUpdate(args: {
onClose: () => void;
queryClient: any;
getConnectionTab: () => "parameters" | "dsn";
+ getSelectedAuthMethod?: () => string | undefined;
setParamsError: (message: string | null, details?: string) => void;
setDsnError: (message: string | null, details?: string) => void;
setShowSaveAnyway?: (value: boolean) => void;
@@ -245,6 +495,7 @@ export class AddDataFormManager {
onClose,
queryClient,
getConnectionTab,
+ getSelectedAuthMethod,
setParamsError,
setDsnError,
setShowSaveAnyway,
@@ -262,27 +513,76 @@ export class AddDataFormManager {
Record
>;
result?: Extract;
+ cancel?: () => void;
}) => {
- // For non-ClickHouse connectors, expose Save Anyway when a submission starts
+ const values = event.form.data;
+ const schema = getConnectorSchema(this.connector.name ?? "");
+ const authKey = schema ? findRadioEnumKey(schema) : null;
+ const selectedAuthMethod =
+ (authKey && values && values[authKey] != null
+ ? String(values[authKey])
+ : undefined) ||
+ getSelectedAuthMethod?.() ||
+ "";
+ const stepState = get(connectorStepStore) as ConnectorStepState;
+
+ // Fast-path: public auth skips validation/test and goes straight to source step.
if (
- isConnectorForm &&
- connector.name !== "clickhouse" &&
- typeof setShowSaveAnyway === "function" &&
- event?.result
+ isMultiStepConnector &&
+ stepState.step === "connector" &&
+ selectedAuthMethod === "public"
) {
- setShowSaveAnyway(true);
+ setConnectorConfig(values);
+ setStep("source");
+ return;
}
- if (!event.form.valid) return;
+ if (isMultiStepConnector && stepState.step === "source") {
+ const sourceValidator = getValidationSchemaForConnector(
+ connector.name as string,
+ "source",
+ );
+ const result = await sourceValidator.validate(values);
+ if (!result.success) {
+ const fieldErrors: Record = {};
+ for (const issue of result.issues ?? []) {
+ const key =
+ issue.path?.[0] != null ? String(issue.path[0]) : "_errors";
+ if (!fieldErrors[key]) fieldErrors[key] = [];
+ fieldErrors[key].push(issue.message);
+ }
+ (this.params.errors as any).set(fieldErrors);
+ event.cancel?.();
+ return;
+ }
+ (this.params.errors as any).set({});
+ } else if (!event.form.valid) {
+ return;
+ }
- const values = event.form.data;
+ if (
+ typeof setShowSaveAnyway === "function" &&
+ this.shouldShowSaveAnywayButton({
+ isConnectorForm,
+ event,
+ stepState,
+ selectedAuthMethod,
+ })
+ ) {
+ setShowSaveAnyway(true);
+ }
try {
- const stepState = get(connectorStepStore) as ConnectorStepState;
if (isMultiStepConnector && stepState.step === "source") {
await submitAddSourceForm(queryClient, connector, values);
onClose();
} else if (isMultiStepConnector && stepState.step === "connector") {
+ // For public auth, skip Test & Connect and go straight to the next step.
+ if (selectedAuthMethod === "public") {
+ setConnectorConfig(values);
+ setStep("source");
+ return;
+ }
await submitAddConnectorForm(queryClient, connector, values, false);
setConnectorConfig(values);
setStep("source");
@@ -392,16 +692,22 @@ export class AddDataFormManager {
clickhouseDsnValues,
} = ctx;
+ const connectorPropertiesForPreview =
+ isMultiStepConnector && stepState?.step === "connector"
+ ? (connector.configProperties ?? [])
+ : filteredParamsProperties;
+
const getConnectorYamlPreview = (values: Record) => {
+ const orderedProperties =
+ onlyDsn || connectionTab === "dsn"
+ ? filteredDsnProperties
+ : connectorPropertiesForPreview;
return compileConnectorYAML(connector, values, {
fieldFilter: (property) => {
if (onlyDsn || connectionTab === "dsn") return true;
return !property.noPrompt;
},
- orderedProperties:
- onlyDsn || connectionTab === "dsn"
- ? filteredDsnProperties
- : filteredParamsProperties,
+ orderedProperties,
});
};
diff --git a/web-common/src/features/sources/modal/AddDataModal.svelte b/web-common/src/features/sources/modal/AddDataModal.svelte
index 42c8289d1b1..e2dd69ade1e 100644
--- a/web-common/src/features/sources/modal/AddDataModal.svelte
+++ b/web-common/src/features/sources/modal/AddDataModal.svelte
@@ -71,6 +71,9 @@
});
function goToConnectorForm(connector: V1ConnectorDriver) {
+ // Reset multi-step state (auth selection, connector config) when switching connectors.
+ resetConnectorStep();
+
const state = {
step: 2,
selectedConnector: connector,
@@ -121,7 +124,7 @@
// FIXME: excluding salesforce until we implement the table discovery APIs
$: isConnectorType =
- selectedConnector?.name === "gcs" ||
+ selectedConnector?.implementsObjectStore ||
selectedConnector?.implementsOlap ||
selectedConnector?.implementsSqlStore ||
(selectedConnector?.implementsWarehouse &&
diff --git a/web-common/src/features/sources/modal/ClickhouseFormRenderer.svelte b/web-common/src/features/sources/modal/ClickhouseFormRenderer.svelte
new file mode 100644
index 00000000000..e78df906d5c
--- /dev/null
+++ b/web-common/src/features/sources/modal/ClickhouseFormRenderer.svelte
@@ -0,0 +1,176 @@
+
+
+
+
+
+ {#if clickhouseConnectorType === "rill-managed"}
+
+
+
+ {/if}
+
+
+ {#if clickhouseConnectorType === "self-hosted" || clickhouseConnectorType === "clickhouse-cloud"}
+
+
+
+ {#each clickhouseUiState?.filteredProperties ?? [] as property (property.key)}
+ {@const propertyKey = property.key ?? ""}
+ {@const isPortField = propertyKey === "port"}
+ {@const isSSLField = propertyKey === "ssl"}
+
+
+ {#if property.type === ConnectorDriverPropertyType.TYPE_STRING || property.type === ConnectorDriverPropertyType.TYPE_NUMBER}
+ onStringInputChange(e)}
+ alwaysShowError
+ options={clickhouseConnectorType === "clickhouse-cloud" &&
+ isPortField
+ ? [
+ { value: "8443", label: "8443 (HTTPS)" },
+ { value: "9440", label: "9440 (Native Secure)" },
+ ]
+ : undefined}
+ />
+ {:else if property.type === ConnectorDriverPropertyType.TYPE_BOOLEAN}
+
+ {:else if property.type === ConnectorDriverPropertyType.TYPE_INFORMATIONAL}
+
+ {/if}
+
+ {/each}
+
+
+
+
+ {#each clickhouseUiState?.dsnProperties ?? [] as property (property.key)}
+ {@const propertyKey = property.key ?? ""}
+
+
+
+ {/each}
+
+
+
+ {:else}
+
+ {#each clickhouseUiState?.filteredProperties ?? [] as property (property.key)}
+ {@const propertyKey = property.key ?? ""}
+
+ {#if property.type === ConnectorDriverPropertyType.TYPE_STRING || property.type === ConnectorDriverPropertyType.TYPE_NUMBER}
+ onStringInputChange(e)}
+ alwaysShowError
+ />
+ {:else if property.type === ConnectorDriverPropertyType.TYPE_BOOLEAN}
+
+ {:else if property.type === ConnectorDriverPropertyType.TYPE_INFORMATIONAL}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
diff --git a/web-common/src/features/sources/modal/FormRenderer.svelte b/web-common/src/features/sources/modal/FormRenderer.svelte
index a114345627f..dc33afc5fff 100644
--- a/web-common/src/features/sources/modal/FormRenderer.svelte
+++ b/web-common/src/features/sources/modal/FormRenderer.svelte
@@ -7,7 +7,7 @@
ConnectorDriverPropertyType,
type ConnectorDriverProperty,
} from "@rilldata/web-common/runtime-client";
- import { normalizeErrors } from "./utils";
+ import { normalizeErrors } from "../../templates/error-utils";
export let properties: Array = [];
export let form: any; // expect a store from parent
diff --git a/web-common/src/features/sources/modal/FormValidation.test.ts b/web-common/src/features/sources/modal/FormValidation.test.ts
new file mode 100644
index 00000000000..722052755c4
--- /dev/null
+++ b/web-common/src/features/sources/modal/FormValidation.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from "vitest";
+
+import { getValidationSchemaForConnector } from "./FormValidation";
+
+describe("getValidationSchemaForConnector (multi-step auth)", () => {
+ it("enforces required fields for access key auth", async () => {
+ const schema = getValidationSchemaForConnector("s3", "connector");
+
+ const result = await schema.validate({});
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("expected validation to fail");
+ expect(result.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: ["aws_access_key_id"] }),
+ expect.objectContaining({ path: ["aws_secret_access_key"] }),
+ ]),
+ );
+ });
+
+ it("allows public auth without credentials", async () => {
+ const schema = getValidationSchemaForConnector("s3", "connector");
+
+ const result = await schema.validate({ auth_method: "public" });
+ expect(result.success).toBe(true);
+ });
+
+ it("requires source fields from JSON schema for multi-step connectors", async () => {
+ const schema = getValidationSchemaForConnector("s3", "source");
+
+ const result = await schema.validate({});
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("expected validation to fail");
+ expect(result.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: ["path"] }),
+ expect.objectContaining({ path: ["name"] }),
+ ]),
+ );
+ });
+
+ it("rejects invalid s3 path on source step", async () => {
+ const schema = getValidationSchemaForConnector("s3", "source");
+
+ const result = await schema.validate({
+ path: "s3:/bucket",
+ name: "valid_name",
+ });
+ expect(result.success).toBe(false);
+ if (result.success) throw new Error("expected validation to fail");
+ expect(result.issues).toEqual(
+ expect.arrayContaining([expect.objectContaining({ path: ["path"] })]),
+ );
+ });
+
+ it("accepts valid s3 path on source step", async () => {
+ const schema = getValidationSchemaForConnector("s3", "source");
+
+ const result = await schema.validate({
+ path: "s3://bucket/prefix",
+ name: "valid_name",
+ });
+ expect(result.success).toBe(true);
+ });
+});
diff --git a/web-common/src/features/sources/modal/FormValidation.ts b/web-common/src/features/sources/modal/FormValidation.ts
index 92c0f87c338..d1ed1a07bf1 100644
--- a/web-common/src/features/sources/modal/FormValidation.ts
+++ b/web-common/src/features/sources/modal/FormValidation.ts
@@ -1,7 +1,16 @@
-import { dsnSchema, getYupSchema } from "./yupSchemas";
+import type { ValidationAdapter } from "sveltekit-superforms/adapters";
-export { dsnSchema };
+import { createSchemasafeValidator } from "./jsonSchemaValidator";
+import { getConnectorSchema } from "./connector-schemas";
+import type { AddDataFormType } from "./types";
-export function getValidationSchemaForConnector(name: string) {
- return getYupSchema[name as keyof typeof getYupSchema];
+export function getValidationSchemaForConnector(
+ name: string,
+ formType: AddDataFormType,
+): ValidationAdapter> {
+ const jsonSchema = getConnectorSchema(name);
+ const step = formType === "source" ? "source" : "connector";
+
+ if (jsonSchema) return createSchemasafeValidator(jsonSchema, step);
+ throw new Error(`No validation schema found for connector: ${name}`);
}
diff --git a/web-common/src/features/sources/modal/GCSMultiStepForm.svelte b/web-common/src/features/sources/modal/GCSMultiStepForm.svelte
deleted file mode 100644
index e007489218a..00000000000
--- a/web-common/src/features/sources/modal/GCSMultiStepForm.svelte
+++ /dev/null
@@ -1,129 +0,0 @@
-
-
-
-
-
-
-
- {#each filteredParamsProperties as property (property.key)}
- {@const propertyKey = property.key ?? ""}
- {#if propertyKey !== "path" && propertyKey !== "google_application_credentials" && propertyKey !== "key_id" && propertyKey !== "secret"}
-
- {#if property.type === ConnectorDriverPropertyType.TYPE_STRING || property.type === ConnectorDriverPropertyType.TYPE_NUMBER}
- onStringInputChange(e)}
- alwaysShowError
- />
- {:else if property.type === ConnectorDriverPropertyType.TYPE_BOOLEAN}
-
- {:else if property.type === ConnectorDriverPropertyType.TYPE_INFORMATIONAL}
-
- {/if}
-
- {/if}
- {/each}
-
diff --git a/web-common/src/features/sources/modal/MultiStepConnectorFlow.svelte b/web-common/src/features/sources/modal/MultiStepConnectorFlow.svelte
new file mode 100644
index 00000000000..1eda032d84d
--- /dev/null
+++ b/web-common/src/features/sources/modal/MultiStepConnectorFlow.svelte
@@ -0,0 +1,174 @@
+
+
+
+
+
diff --git a/web-common/src/features/sources/modal/connector-schemas.ts b/web-common/src/features/sources/modal/connector-schemas.ts
new file mode 100644
index 00000000000..0e2346fbbd5
--- /dev/null
+++ b/web-common/src/features/sources/modal/connector-schemas.ts
@@ -0,0 +1,49 @@
+import type { MultiStepFormSchema } from "../../templates/schemas/types";
+import { athenaSchema } from "../../templates/schemas/athena";
+import { azureSchema } from "../../templates/schemas/azure";
+import { bigquerySchema } from "../../templates/schemas/bigquery";
+import { clickhouseSchema } from "../../templates/schemas/clickhouse";
+import { gcsSchema } from "../../templates/schemas/gcs";
+import { mysqlSchema } from "../../templates/schemas/mysql";
+import { postgresSchema } from "../../templates/schemas/postgres";
+import { redshiftSchema } from "../../templates/schemas/redshift";
+import { salesforceSchema } from "../../templates/schemas/salesforce";
+import { snowflakeSchema } from "../../templates/schemas/snowflake";
+import { sqliteSchema } from "../../templates/schemas/sqlite";
+import { localFileSchema } from "../../templates/schemas/local_file";
+import { duckdbSchema } from "../../templates/schemas/duckdb";
+import { httpsSchema } from "../../templates/schemas/https";
+import { motherduckSchema } from "../../templates/schemas/motherduck";
+import { druidSchema } from "../../templates/schemas/druid";
+import { pinotSchema } from "../../templates/schemas/pinot";
+import { s3Schema } from "../../templates/schemas/s3";
+
+export const multiStepFormSchemas: Record = {
+ athena: athenaSchema,
+ bigquery: bigquerySchema,
+ clickhouse: clickhouseSchema,
+ mysql: mysqlSchema,
+ postgres: postgresSchema,
+ redshift: redshiftSchema,
+ salesforce: salesforceSchema,
+ snowflake: snowflakeSchema,
+ sqlite: sqliteSchema,
+ motherduck: motherduckSchema,
+ duckdb: duckdbSchema,
+ druid: druidSchema,
+ pinot: pinotSchema,
+ local_file: localFileSchema,
+ https: httpsSchema,
+ s3: s3Schema,
+ gcs: gcsSchema,
+ azure: azureSchema,
+};
+
+export function getConnectorSchema(
+ connectorName: string,
+): MultiStepFormSchema | null {
+ const schema =
+ multiStepFormSchemas[connectorName as keyof typeof multiStepFormSchemas];
+ if (!schema?.properties) return null;
+ return schema;
+}
diff --git a/web-common/src/features/sources/modal/connectorStepStore.ts b/web-common/src/features/sources/modal/connectorStepStore.ts
index 9ce2451f126..1168a65e532 100644
--- a/web-common/src/features/sources/modal/connectorStepStore.ts
+++ b/web-common/src/features/sources/modal/connectorStepStore.ts
@@ -2,12 +2,16 @@ import { writable } from "svelte/store";
export type ConnectorStep = "connector" | "source";
-export const connectorStepStore = writable<{
+export type ConnectorStepState = {
step: ConnectorStep;
connectorConfig: Record | null;
-}>({
+ selectedAuthMethod: string | null;
+};
+
+export const connectorStepStore = writable({
step: "connector",
connectorConfig: null,
+ selectedAuthMethod: null,
});
export function setStep(step: ConnectorStep) {
@@ -18,9 +22,17 @@ export function setConnectorConfig(config: Record) {
connectorStepStore.update((state) => ({ ...state, connectorConfig: config }));
}
+export function setAuthMethod(method: string | null) {
+ connectorStepStore.update((state) => ({
+ ...state,
+ selectedAuthMethod: method,
+ }));
+}
+
export function resetConnectorStep() {
connectorStepStore.set({
step: "connector",
connectorConfig: null,
+ selectedAuthMethod: null,
});
}
diff --git a/web-common/src/features/sources/modal/constants.ts b/web-common/src/features/sources/modal/constants.ts
index 4bffc52825d..8e02f8a7b44 100644
--- a/web-common/src/features/sources/modal/constants.ts
+++ b/web-common/src/features/sources/modal/constants.ts
@@ -17,7 +17,7 @@ export const CONNECTION_TAB_OPTIONS: { value: string; label: string }[] = [
{ value: "dsn", label: "Enter connection string" },
];
-export type GCSAuthMethod = "credentials" | "hmac";
+export type GCSAuthMethod = "public" | "credentials" | "hmac";
export const GCS_AUTH_OPTIONS: {
value: GCSAuthMethod;
@@ -37,6 +37,65 @@ export const GCS_AUTH_OPTIONS: {
description:
"Use HMAC access key and secret for S3-compatible authentication.",
},
+ {
+ value: "public",
+ label: "Public",
+ description: "Access publicly readable buckets without credentials.",
+ },
+];
+
+export type S3AuthMethod = "access_keys" | "public";
+
+export const S3_AUTH_OPTIONS: {
+ value: S3AuthMethod;
+ label: string;
+ description: string;
+ hint?: string;
+}[] = [
+ {
+ value: "access_keys",
+ label: "Access keys",
+ description: "Use AWS access key ID and secret access key.",
+ },
+ {
+ value: "public",
+ label: "Public",
+ description: "Access publicly readable buckets without credentials.",
+ },
+];
+
+export type AzureAuthMethod =
+ | "account_key"
+ | "sas_token"
+ | "connection_string"
+ | "public";
+
+export const AZURE_AUTH_OPTIONS: {
+ value: AzureAuthMethod;
+ label: string;
+ description: string;
+ hint?: string;
+}[] = [
+ {
+ value: "connection_string",
+ label: "Connection String",
+ description: "Alternative for cloud deployment",
+ },
+ {
+ value: "account_key",
+ label: "Storage Account Key",
+ description: "Recommended for cloud deployment",
+ },
+ {
+ value: "sas_token",
+ label: "Shared Access Signature (SAS) Token",
+ description: "Most secure, fine-grained control",
+ },
+ {
+ value: "public",
+ label: "Public",
+ description: "Access publicly readable blobs without credentials.",
+ },
];
// pre-defined order for sources
@@ -67,7 +126,7 @@ export const OLAP_ENGINES = [
export const ALL_CONNECTORS = [...SOURCES, ...OLAP_ENGINES];
// Connectors that support multi-step forms (connector -> source)
-export const MULTI_STEP_CONNECTORS = ["gcs"];
+export const MULTI_STEP_CONNECTORS = ["gcs", "s3", "azure"];
export const FORM_HEIGHT_TALL = "max-h-[38.5rem] min-h-[38.5rem]";
export const FORM_HEIGHT_DEFAULT = "max-h-[34.5rem] min-h-[34.5rem]";
diff --git a/web-common/src/features/sources/modal/jsonSchemaValidator.ts b/web-common/src/features/sources/modal/jsonSchemaValidator.ts
new file mode 100644
index 00000000000..1804f0713b4
--- /dev/null
+++ b/web-common/src/features/sources/modal/jsonSchemaValidator.ts
@@ -0,0 +1,193 @@
+import type { ValidatorOptions, ValidationError } from "@exodus/schemasafe";
+import { validator as compileValidator } from "@exodus/schemasafe";
+import { schemasafe } from "sveltekit-superforms/adapters";
+import type { ValidationAdapter } from "sveltekit-superforms/adapters";
+
+import { getFieldLabel, isStepMatch } from "../../templates/schema-utils";
+import type {
+ JSONSchemaConditional,
+ JSONSchemaConstraint,
+ MultiStepFormSchema,
+} from "../../templates/schemas/types";
+
+const DEFAULT_SCHEMASAFE_OPTIONS: ValidatorOptions = {
+ includeErrors: true,
+ allErrors: true,
+ allowUnusedKeywords: true,
+ formats: {
+ uri: (value: string) => {
+ if (typeof value !== "string") return false;
+ try {
+ // Allow custom schemes such as s3:// or gs://
+ new URL(value);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ // We treat file inputs as strings; superforms handles the upload.
+ file: () => true,
+ },
+};
+
+type Step = "connector" | "source" | string | undefined;
+
+export function buildStepSchema(
+ schema: MultiStepFormSchema,
+ step: Step,
+): MultiStepFormSchema {
+ const properties = Object.entries(schema.properties ?? {}).reduce<
+ NonNullable
+ >((acc, [key, prop]) => {
+ if (!isStepMatch(schema, key, step)) return acc;
+ acc[key] = prop;
+ return acc;
+ }, {});
+
+ const required = (schema.required ?? []).filter((key) =>
+ isStepMatch(schema, key, step),
+ );
+
+ const filteredAllOf = (schema.allOf ?? [])
+ .map((conditional) => filterConditional(conditional, schema, step))
+ .filter((conditional): conditional is JSONSchemaConditional =>
+ Boolean(
+ conditional &&
+ ((conditional.then?.required?.length ?? 0) > 0 ||
+ (conditional.else?.required?.length ?? 0) > 0),
+ ),
+ );
+
+ return {
+ $schema: schema.$schema,
+ type: "object",
+ properties,
+ ...(required.length ? { required } : {}),
+ ...(filteredAllOf.length ? { allOf: filteredAllOf } : {}),
+ };
+}
+
+export function createSchemasafeValidator(
+ schema: MultiStepFormSchema,
+ step: Step,
+ opts?: { config?: ValidatorOptions },
+): ValidationAdapter> {
+ const stepSchema = buildStepSchema(schema, step);
+ const validator = compileValidator(stepSchema, {
+ ...DEFAULT_SCHEMASAFE_OPTIONS,
+ ...opts?.config,
+ });
+
+ const baseAdapter = schemasafe(stepSchema, {
+ config: {
+ ...DEFAULT_SCHEMASAFE_OPTIONS,
+ ...opts?.config,
+ },
+ });
+
+ return {
+ ...baseAdapter,
+ async validate(data: Record = {}) {
+ const pruned = pruneEmptyFields(data);
+ const isValid = validator(pruned as any);
+ if (isValid) {
+ return { data: pruned, success: true };
+ }
+
+ const issues = (validator.errors ?? []).map((error) =>
+ toIssue(error, schema),
+ );
+ return { success: false, issues };
+ },
+ };
+}
+
+function pruneEmptyFields(
+ values: Record,
+): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(values ?? {})) {
+ if (value === "" || value === null || value === undefined) continue;
+ result[key] = value;
+ }
+ return result;
+}
+
+function filterConditional(
+ conditional: JSONSchemaConditional,
+ schema: MultiStepFormSchema,
+ step: Step,
+): JSONSchemaConditional | null {
+ const thenRequired = filterRequired(conditional.then, schema, step);
+ const elseRequired = filterRequired(conditional.else, schema, step);
+
+ if (!thenRequired.length && !elseRequired.length) return null;
+
+ return {
+ if: conditional.if,
+ then: thenRequired.length ? { required: thenRequired } : undefined,
+ else: elseRequired.length ? { required: elseRequired } : undefined,
+ };
+}
+
+function filterRequired(
+ constraint: JSONSchemaConstraint | undefined,
+ schema: MultiStepFormSchema,
+ step: Step,
+): string[] {
+ return (constraint?.required ?? []).filter((key) =>
+ isStepMatch(schema, key, step),
+ );
+}
+
+function toIssue(error: ValidationError, schema: MultiStepFormSchema) {
+ const pathSegments = parseInstanceLocation(error.instanceLocation);
+ const key = pathSegments[0];
+ return {
+ path: pathSegments,
+ message: buildMessage(schema, key, error),
+ };
+}
+
+function buildMessage(
+ schema: MultiStepFormSchema,
+ key: string | undefined,
+ error: ValidationError,
+): string {
+ if (!key) return "Invalid value";
+ const prop = schema.properties?.[key] as any;
+ const label = getFieldLabel(schema, key);
+ const keyword = parseKeyword(error.keywordLocation);
+
+ if (keyword === "required") return `${label} is required`;
+
+ if (keyword === "pattern") {
+ const custom = prop?.errorMessage?.pattern as string | undefined;
+ return custom || `${label} is invalid`;
+ }
+
+ if (keyword === "format") {
+ const custom = prop?.errorMessage?.format as string | undefined;
+ const format = prop?.format as string | undefined;
+ if (custom) return custom;
+ if (format) return `${label} must be a valid ${format}`;
+ return `${label} is invalid`;
+ }
+
+ if (keyword === "type" && prop?.type) {
+ return `${label} must be a ${prop.type}`;
+ }
+
+ return `${label} is invalid`;
+}
+
+function parseInstanceLocation(location: string): string[] {
+ if (!location || location === "#") return [];
+ return location.replace(/^#\//, "").split("/").filter(Boolean);
+}
+
+function parseKeyword(location: string): string {
+ if (!location) return "";
+ const parts = location.split("/");
+ return parts[parts.length - 1] || "";
+}
diff --git a/web-common/src/features/sources/modal/submitAddDataForm.ts b/web-common/src/features/sources/modal/submitAddDataForm.ts
index c6ec540ad6e..d9bdbd6f332 100644
--- a/web-common/src/features/sources/modal/submitAddDataForm.ts
+++ b/web-common/src/features/sources/modal/submitAddDataForm.ts
@@ -154,88 +154,6 @@ async function getOriginalEnvBlob(
}
}
-export async function submitAddSourceForm(
- queryClient: QueryClient,
- connector: V1ConnectorDriver,
- formValues: AddDataFormValues,
-): Promise {
- const instanceId = get(runtime).instanceId;
- await beforeSubmitForm(instanceId, connector);
-
- const newSourceName = formValues.name as string;
-
- const [rewrittenConnector, rewrittenFormValues] = prepareSourceFormData(
- connector,
- formValues,
- );
-
- // Make a new .yaml file
- const newSourceFilePath = getFileAPIPathFromNameAndType(
- newSourceName,
- EntityType.Table,
- );
- await runtimeServicePutFile(instanceId, {
- path: newSourceFilePath,
- blob: compileSourceYAML(rewrittenConnector, rewrittenFormValues),
- create: true,
- createOnly: false,
- });
-
- const originalEnvBlob = await getOriginalEnvBlob(queryClient, instanceId);
-
- // Create or update the `.env` file
- const newEnvBlob = await updateDotEnvWithSecrets(
- queryClient,
- rewrittenConnector,
- rewrittenFormValues,
- "source",
- );
-
- // Make sure the file has reconciled before testing the connection
- await runtimeServicePutFileAndWaitForReconciliation(instanceId, {
- path: ".env",
- blob: newEnvBlob,
- create: true,
- createOnly: false,
- });
-
- // Wait for source resource-level reconciliation
- // This must happen after .env reconciliation since sources depend on secrets
- try {
- await waitForResourceReconciliation(
- instanceId,
- newSourceName,
- ResourceKind.Model,
- );
- } catch (error) {
- // The source file was already created, so we need to delete it
- await rollbackChanges(instanceId, newSourceFilePath, originalEnvBlob);
- const errorDetails = (error as any).details;
-
- throw {
- message: error.message || "Unable to establish a connection",
- details:
- errorDetails && errorDetails !== error.message
- ? errorDetails
- : undefined,
- };
- }
-
- // Check for file errors
- // If the model file has errors, rollback the changes
- const errorMessage = await fileArtifacts.checkFileErrors(
- queryClient,
- instanceId,
- newSourceFilePath,
- );
- if (errorMessage) {
- await rollbackChanges(instanceId, newSourceFilePath, originalEnvBlob);
- throw new Error(errorMessage);
- }
-
- await goto(`/files/${newSourceFilePath}`);
-}
-
async function saveConnectorAnyway(
queryClient: QueryClient,
connector: V1ConnectorDriver,
@@ -489,3 +407,85 @@ export async function submitAddConnectorForm(
// Wait for the submission to complete
await submissionPromise;
}
+
+export async function submitAddSourceForm(
+ queryClient: QueryClient,
+ connector: V1ConnectorDriver,
+ formValues: AddDataFormValues,
+): Promise {
+ const instanceId = get(runtime).instanceId;
+ await beforeSubmitForm(instanceId, connector);
+
+ const newSourceName = formValues.name as string;
+
+ const [rewrittenConnector, rewrittenFormValues] = prepareSourceFormData(
+ connector,
+ formValues,
+ );
+
+ // Make a new .yaml file
+ const newSourceFilePath = getFileAPIPathFromNameAndType(
+ newSourceName,
+ EntityType.Table,
+ );
+ await runtimeServicePutFile(instanceId, {
+ path: newSourceFilePath,
+ blob: compileSourceYAML(rewrittenConnector, rewrittenFormValues),
+ create: true,
+ createOnly: false,
+ });
+
+ const originalEnvBlob = await getOriginalEnvBlob(queryClient, instanceId);
+
+ // Create or update the `.env` file
+ const newEnvBlob = await updateDotEnvWithSecrets(
+ queryClient,
+ rewrittenConnector,
+ rewrittenFormValues,
+ "source",
+ );
+
+ // Make sure the file has reconciled before testing the connection
+ await runtimeServicePutFileAndWaitForReconciliation(instanceId, {
+ path: ".env",
+ blob: newEnvBlob,
+ create: true,
+ createOnly: false,
+ });
+
+ // Wait for source resource-level reconciliation
+ // This must happen after .env reconciliation since sources depend on secrets
+ try {
+ await waitForResourceReconciliation(
+ instanceId,
+ newSourceName,
+ ResourceKind.Model,
+ );
+ } catch (error) {
+ // The source file was already created, so we need to delete it
+ await rollbackChanges(instanceId, newSourceFilePath, originalEnvBlob);
+ const errorDetails = (error as any).details;
+
+ throw {
+ message: error.message || "Unable to establish a connection",
+ details:
+ errorDetails && errorDetails !== error.message
+ ? errorDetails
+ : undefined,
+ };
+ }
+
+ // Check for file errors
+ // If the model file has errors, rollback the changes
+ const errorMessage = await fileArtifacts.checkFileErrors(
+ queryClient,
+ instanceId,
+ newSourceFilePath,
+ );
+ if (errorMessage) {
+ await rollbackChanges(instanceId, newSourceFilePath, originalEnvBlob);
+ throw new Error(errorMessage);
+ }
+
+ await goto(`/files/${newSourceFilePath}`);
+}
diff --git a/web-common/src/features/sources/modal/types.ts b/web-common/src/features/sources/modal/types.ts
index 5cb0785a5a5..8fd405fe6de 100644
--- a/web-common/src/features/sources/modal/types.ts
+++ b/web-common/src/features/sources/modal/types.ts
@@ -1,3 +1,51 @@
+import type { MultiStepFormSchema } from "../../templates/schemas/types";
+
+export type {
+ JSONSchemaCondition,
+ JSONSchemaConditional,
+ JSONSchemaConstraint,
+ JSONSchemaField,
+ JSONSchemaObject,
+ MultiStepFormSchema,
+} from "../../templates/schemas/types";
+
export type AddDataFormType = "source" | "connector";
export type ConnectorType = "parameters" | "dsn";
+
+export type AuthOption = {
+ value: string;
+ label: string;
+ description: string;
+ hint?: string;
+};
+
+export type AuthField =
+ | {
+ type: "credentials";
+ id: string;
+ hint?: string;
+ optional?: boolean;
+ accept?: string;
+ }
+ | {
+ type: "input";
+ id: string;
+ label: string;
+ placeholder?: string;
+ optional?: boolean;
+ secret?: boolean;
+ hint?: string;
+ };
+
+export type MultiStepFormConfig = {
+ schema: MultiStepFormSchema;
+ authMethodKey: string;
+ authOptions: AuthOption[];
+ clearFieldsByMethod: Record;
+ excludedKeys: string[];
+ authFieldGroups: Record;
+ requiredFieldsByMethod: Record;
+ fieldLabels: Record;
+ defaultAuthMethod?: string;
+};
diff --git a/web-common/src/features/sources/modal/utils.ts b/web-common/src/features/sources/modal/utils.ts
index 5a7128802d2..6bd2b9106f4 100644
--- a/web-common/src/features/sources/modal/utils.ts
+++ b/web-common/src/features/sources/modal/utils.ts
@@ -1,6 +1,13 @@
import { humanReadableErrorMessage } from "../errors/errors";
import type { V1ConnectorDriver } from "@rilldata/web-common/runtime-client";
import type { ClickHouseConnectorType } from "./constants";
+import type { MultiStepFormSchema } from "./types";
+import {
+ findRadioEnumKey,
+ getRadioEnumOptions,
+ getRequiredFieldsByEnumValue,
+ isStepMatch,
+} from "../../templates/schema-utils";
/**
* Returns true for undefined, null, empty string, or whitespace-only string.
@@ -22,16 +29,6 @@ export function isEmpty(val: any) {
* - If input resembles a Zod `_errors` array, returns that.
* - Otherwise returns undefined.
*/
-export function normalizeErrors(
- err: any,
-): string | string[] | null | undefined {
- if (!err) return undefined;
- if (Array.isArray(err)) return err;
- if (typeof err === "string") return err;
- if (err._errors && Array.isArray(err._errors)) return err._errors;
- return undefined;
-}
-
/**
* Converts unknown error inputs into a unified connector error shape.
* - Prefers native Error.message when present
@@ -83,6 +80,104 @@ export function hasOnlyDsn(
return hasDsn && !hasOthers;
}
+/**
+ * Returns true when the active multi-step auth method has missing or invalid
+ * required fields. Falls back to configured default/first auth method.
+ */
+export function isMultiStepConnectorDisabled(
+ schema: MultiStepFormSchema | null,
+ paramsFormValue: Record,
+ paramsFormErrors: Record,
+ step?: "connector" | "source" | string,
+) {
+ if (!schema) return true;
+
+ // For source step, gate on required fields from the JSON schema.
+ const currentStep = step || (paramsFormValue?.__step as string | undefined);
+ if (currentStep === "source") {
+ const required = getRequiredFieldsForStep(
+ schema,
+ paramsFormValue,
+ "source",
+ );
+ if (!required.length) return false;
+ return !required.every((fieldId) => {
+ if (!isStepMatch(schema, fieldId, "source")) return true;
+ const value = paramsFormValue[fieldId];
+ const errorsForField = paramsFormErrors[fieldId] as any;
+ const hasErrors = Boolean(errorsForField?.length);
+ return !isEmpty(value) && !hasErrors;
+ });
+ }
+
+ const authInfo = getRadioEnumOptions(schema);
+ const options = authInfo?.options ?? [];
+ const authKey = authInfo?.key || findRadioEnumKey(schema);
+ const methodFromForm =
+ authKey && paramsFormValue?.[authKey] != null
+ ? String(paramsFormValue[authKey])
+ : undefined;
+ const hasValidFormSelection = options.some(
+ (opt) => opt.value === methodFromForm,
+ );
+ const method =
+ (hasValidFormSelection && methodFromForm) ||
+ authInfo?.defaultValue ||
+ options[0]?.value;
+
+ if (!method) return true;
+
+ // Selecting "public" should always enable the button for multi-step auth flows.
+ if (method === "public") return false;
+
+ const requiredByMethod = getRequiredFieldsByEnumValue(schema, {
+ step: "connector",
+ });
+ const requiredFields = requiredByMethod[method] ?? [];
+ if (!requiredFields.length) return true;
+
+ return !requiredFields.every((fieldId) => {
+ if (!isStepMatch(schema, fieldId, "connector")) return true;
+ const value = paramsFormValue[fieldId];
+ const errorsForField = paramsFormErrors[fieldId] as any;
+ const hasErrors = Boolean(errorsForField?.length);
+ return !isEmpty(value) && !hasErrors;
+ });
+}
+
+function getRequiredFieldsForStep(
+ schema: MultiStepFormSchema,
+ values: Record,
+ step: "connector" | "source" | string,
+) {
+ const required = new Set();
+ (schema.required ?? []).forEach((key) => {
+ if (isStepMatch(schema, key, step)) required.add(key);
+ });
+
+ for (const conditional of schema.allOf ?? []) {
+ const condition = conditional.if?.properties;
+ const matches = matchesCondition(condition, values);
+ const branch = matches ? conditional.then : conditional.else;
+ branch?.required?.forEach((key) => {
+ if (isStepMatch(schema, key, step)) required.add(key);
+ });
+ }
+
+ return Array.from(required);
+}
+
+function matchesCondition(
+ condition: Record | undefined,
+ values: Record,
+) {
+ if (!condition || !Object.keys(condition).length) return false;
+ return Object.entries(condition).every(([depKey, def]) => {
+ if (def.const === undefined || def.const === null) return false;
+ return String(values?.[depKey]) === String(def.const);
+ });
+}
+
/**
* Applies ClickHouse Cloud-specific default requirements for connector values.
* - For ClickHouse Cloud: enforces `ssl: true`
diff --git a/web-common/src/features/sources/modal/yupSchemas.ts b/web-common/src/features/sources/modal/yupSchemas.ts
deleted file mode 100644
index b12ec332eae..00000000000
--- a/web-common/src/features/sources/modal/yupSchemas.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import * as yup from "yup";
-import {
- INVALID_NAME_MESSAGE,
- VALID_NAME_PATTERN,
-} from "../../entity-management/name-utils";
-
-export const getYupSchema = {
- s3: yup.object().shape({
- path: yup
- .string()
- .matches(/^s3:\/\//, "Must be an S3 URI (e.g. s3://bucket/path)")
- .required("S3 URI is required"),
- aws_region: yup.string(),
- name: yup
- .string()
- .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- .required("Source name is required"),
- }),
-
- gcs: yup.object().shape({
- google_application_credentials: yup.string().optional(),
- key_id: yup.string().optional(),
- secret: yup.string().optional(),
- path: yup
- .string()
- .matches(/^gs:\/\//, "Must be a GS URI (e.g. gs://bucket/path)")
- .optional(),
- }),
-
- https: yup.object().shape({
- path: yup
- .string()
- .matches(/^https?:\/\//, 'Path must start with "http(s)://"')
- .required("Path is required"),
- name: yup
- .string()
- .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- .required("Source name is required"),
- }),
-
- duckdb: yup.object().shape({
- path: yup.string().required("path is required"),
- attach: yup.string().optional(),
- }),
-
- motherduck: yup.object().shape({
- token: yup.string().required("Token is required"),
- path: yup.string().required("Path is required"),
- schema_name: yup.string().required("Schema name is required"),
- }),
-
- sqlite: yup.object().shape({
- db: yup.string().required("db is required"),
- table: yup.string().required("table is required"),
- name: yup
- .string()
- .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- .required("Source name is required"),
- }),
-
- bigquery: yup.object().shape({
- project_id: yup.string(),
- google_application_credentials: yup
- .string()
- .required("Google application credentials is required"),
- }),
-
- azure: yup.object().shape({
- path: yup
- .string()
- .matches(
- /^azure:\/\//,
- "Must be an Azure URI (e.g. azure://container/path)",
- )
- .required("Path is required"),
- azure_storage_account: yup.string(),
- name: yup
- .string()
- .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- .required("Source name is required"),
- }),
-
- postgres: yup.object().shape({
- dsn: yup.string().optional(),
- host: yup.string().optional(),
- port: yup.string().optional(),
- user: yup.string().optional(),
- password: yup.string().optional(),
- dbname: yup.string().optional(),
- sslmode: yup.string().optional(),
- }),
-
- snowflake: yup.object().shape({
- dsn: yup.string().optional(),
- account: yup.string().required("Account is required"),
- user: yup.string().required("Username is required"),
- password: yup.string().required("Password is required"),
- database: yup.string().optional(),
- schema: yup.string().optional(),
- warehouse: yup.string().optional(),
- role: yup.string().optional(),
- }),
-
- salesforce: yup.object().shape({
- soql: yup.string().required("soql is required"),
- sobject: yup.string().required("sobject is required"),
- name: yup
- .string()
- .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- .required("Source name is required"),
- }),
-
- athena: yup.object().shape({
- aws_access_key_id: yup.string().required("AWS access key ID is required"),
- aws_secret_access_key: yup
- .string()
- .required("AWS secret access key is required"),
- output_location: yup.string().required("S3 URI is required"),
- }),
-
- redshift: yup.object().shape({
- aws_access_key_id: yup.string().required("AWS access key ID is required"),
- aws_secret_access_key: yup
- .string()
- .required("AWS secret access key is required"),
- workgroup: yup.string().optional(),
- region: yup.string().optional(), // TODO: add validation
- database: yup.string().required("database name is required"),
- }),
-
- mysql: yup.object().shape({
- dsn: yup.string().optional(),
- user: yup.string().optional(),
- password: yup.string().optional(),
- host: yup.string().optional(),
- port: yup.string().optional(),
- database: yup.string().optional(),
- sslmode: yup.string().optional(),
- }),
-
- clickhouse: yup.object().shape({
- dsn: yup.string().optional(),
- managed: yup.boolean(),
- host: yup.string(),
- // .required("Host is required")
- // .matches(
- // /^(?!https?:\/\/)[a-zA-Z0-9.-]+$/,
- // "Do not prefix the host with `http(s)://`", // It will be added by the runtime
- // ),
- port: yup
- .string() // Purposefully using a string input, not a numeric input
- .matches(/^\d+$/, "Port must be a number"),
- username: yup.string(),
- password: yup.string(),
- cluster: yup.string(),
- ssl: yup.boolean(),
- name: yup.string(), // Required for typing
- // User-provided connector names requires a little refactor. Commenting out for now.
- // name: yup
- // .string()
- // .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- // .required("Connector name is required"),
- }),
-
- druid: yup.object().shape({
- host: yup
- .string()
- .required("Host is required")
- .matches(
- /^(?!https?:\/\/)[a-zA-Z0-9.-]+$/,
- "Do not prefix the host with `http(s)://`", // It will be added by the runtime
- ),
- port: yup
- .string() // Purposefully using a string input, not a numeric input
- .matches(/^\d+$/, "Port must be a number"),
- username: yup.string(),
- password: yup.string(),
- ssl: yup.boolean(),
- name: yup.string(), // Required for typing
- // User-provided connector names requires a little refactor. Commenting out for now.
- // name: yup
- // .string()
- // .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- // .required("Connector name is required"),
- }),
-
- pinot: yup.object().shape({
- broker_host: yup
- .string()
- .required("Broker host is required")
- .matches(
- /^(?!https?:\/\/)[a-zA-Z0-9.-]+$/,
- "Do not prefix the host with `http(s)://`", // It will be added by the runtime
- ),
- broker_port: yup
- .string() // Purposefully using a string input, not a numeric input
- .matches(/^\d+$/, "Port must be a number"),
- controller_host: yup
- .string()
- .required("Controller host is required")
- .matches(
- /^(?!https?:\/\/)[a-zA-Z0-9.-]+$/,
- "Do not prefix the host with `http(s)://`", // It will be added by the runtime
- ),
- controller_port: yup
- .string() // Purposefully using a string input, not a numeric input
- .matches(/^\d+$/, "Port must be a number"),
- username: yup.string(),
- password: yup.string(),
- ssl: yup.boolean(),
- name: yup.string(), // Required for typing
- // User-provided connector names requires a little refactor. Commenting out for now.
- // name: yup
- // .string()
- // .matches(VALID_NAME_PATTERN, INVALID_NAME_MESSAGE)
- // .required("Connector name is required"),
- }),
-};
-
-export const dsnSchema = yup.object().shape({
- dsn: yup.string().required("DSN is required"),
-});
diff --git a/web-common/src/features/templates/JSONSchemaFormRenderer.svelte b/web-common/src/features/templates/JSONSchemaFormRenderer.svelte
new file mode 100644
index 00000000000..3d012838118
--- /dev/null
+++ b/web-common/src/features/templates/JSONSchemaFormRenderer.svelte
@@ -0,0 +1,268 @@
+
+
+{#if schema}
+ {#each renderOrder as [key, prop]}
+ {#if isRadioEnum(prop)}
+
+ {#if prop.title}
+
{prop.title}
+ {/if}
+
+
+ {#if groupedFields.get(key)}
+ {#each getGroupedFieldsForOption(key, option.value) as [childKey, childProp]}
+
+
+
+ {/each}
+ {/if}
+
+
+
+ {:else}
+
+
+
+ {/if}
+ {/each}
+{/if}
diff --git a/web-common/src/features/templates/SchemaField.svelte b/web-common/src/features/templates/SchemaField.svelte
new file mode 100644
index 00000000000..3bfa6783001
--- /dev/null
+++ b/web-common/src/features/templates/SchemaField.svelte
@@ -0,0 +1,66 @@
+
+
+{#if prop["x-display"] === "file" || prop.format === "file"}
+
+{:else if prop.type === "boolean"}
+
+{:else if options?.length}
+
+{:else}
+ onStringInputChange(e)}
+ alwaysShowError
+ />
+{/if}
diff --git a/web-common/src/features/templates/error-utils.ts b/web-common/src/features/templates/error-utils.ts
new file mode 100644
index 00000000000..1c2c74dcbf4
--- /dev/null
+++ b/web-common/src/features/templates/error-utils.ts
@@ -0,0 +1,16 @@
+/**
+ * Normalizes a variety of error shapes into a string, string[], or undefined.
+ * - If input is an array, returns it as-is.
+ * - If input is a string, returns it.
+ * - If input resembles a Zod `_errors` array, returns that.
+ * - Otherwise returns undefined.
+ */
+export function normalizeErrors(
+ err: any,
+): string | string[] | null | undefined {
+ if (!err) return undefined;
+ if (Array.isArray(err)) return err;
+ if (typeof err === "string") return err;
+ if (err._errors && Array.isArray(err._errors)) return err._errors;
+ return undefined;
+}
diff --git a/web-common/src/features/templates/schema-utils.ts b/web-common/src/features/templates/schema-utils.ts
new file mode 100644
index 00000000000..a4544947297
--- /dev/null
+++ b/web-common/src/features/templates/schema-utils.ts
@@ -0,0 +1,154 @@
+import type {
+ JSONSchemaConditional,
+ MultiStepFormSchema,
+} from "./schemas/types";
+
+export type RadioEnumOption = {
+ value: string;
+ label: string;
+ description: string;
+ hint?: string;
+};
+
+export function isStepMatch(
+ schema: MultiStepFormSchema | null,
+ key: string,
+ step?: "connector" | "source" | string,
+): boolean {
+ if (!schema?.properties) return false;
+ const prop = schema.properties[key];
+ if (!prop) return false;
+ if (!step) return true;
+ const propStep = prop["x-step"];
+ if (!propStep) return true;
+ return propStep === step;
+}
+
+export function isVisibleForValues(
+ schema: MultiStepFormSchema,
+ key: string,
+ values: Record,
+): boolean {
+ const prop = schema.properties?.[key];
+ if (!prop) return false;
+ const conditions = prop["x-visible-if"];
+ if (!conditions) return true;
+
+ return Object.entries(conditions).every(([depKey, expected]) => {
+ const actual = values?.[depKey];
+ if (Array.isArray(expected)) {
+ return expected.map(String).includes(String(actual));
+ }
+ return String(actual) === String(expected);
+ });
+}
+
+export function getFieldLabel(
+ schema: MultiStepFormSchema,
+ key: string,
+): string {
+ return schema.properties?.[key]?.title || key;
+}
+
+export function findRadioEnumKey(schema: MultiStepFormSchema): string | null {
+ if (!schema.properties) return null;
+ for (const [key, value] of Object.entries(schema.properties)) {
+ if (value.enum && value["x-display"] === "radio") {
+ return key;
+ }
+ }
+ return null;
+}
+
+export function getRadioEnumOptions(schema: MultiStepFormSchema): {
+ key: string;
+ options: RadioEnumOption[];
+ defaultValue?: string;
+} | null {
+ const enumKey = findRadioEnumKey(schema);
+ if (!enumKey) return null;
+ const enumProperty = schema.properties?.[enumKey];
+ if (!enumProperty?.enum) return null;
+
+ const labels = enumProperty["x-enum-labels"] ?? [];
+ const descriptions = enumProperty["x-enum-descriptions"] ?? [];
+ const options =
+ enumProperty.enum?.map((value, idx) => ({
+ value: String(value),
+ label: labels[idx] ?? String(value),
+ description:
+ descriptions[idx] ?? enumProperty.description ?? "Choose an option",
+ hint: enumProperty["x-hint"],
+ })) ?? [];
+
+ const defaultValue =
+ enumProperty.default !== undefined && enumProperty.default !== null
+ ? String(enumProperty.default)
+ : options[0]?.value;
+
+ return {
+ key: enumKey,
+ options,
+ defaultValue: defaultValue || undefined,
+ };
+}
+
+export function getRequiredFieldsByEnumValue(
+ schema: MultiStepFormSchema,
+ opts?: { step?: "connector" | "source" | string },
+): Record {
+ const enumInfo = getRadioEnumOptions(schema);
+ if (!enumInfo) return {};
+
+ const conditionals = schema.allOf ?? [];
+ const baseRequired = new Set(schema.required ?? []);
+ const result: Record = {};
+
+ const matchesStep = (field: string) => {
+ if (!opts?.step) return true;
+ const prop = schema.properties?.[field];
+ if (!prop) return false;
+ const propStep = prop["x-step"];
+ if (!propStep) return true;
+ return propStep === opts.step;
+ };
+
+ for (const option of enumInfo.options) {
+ const required = new Set();
+
+ baseRequired.forEach((field) => {
+ if (matchesStep(field)) {
+ required.add(field);
+ }
+ });
+
+ for (const conditional of conditionals) {
+ const matches = matchesEnumCondition(
+ conditional,
+ enumInfo.key,
+ option.value,
+ );
+ const target = matches ? conditional.then : conditional.else;
+ target?.required?.forEach((field) => {
+ if (matchesStep(field)) {
+ required.add(field);
+ }
+ });
+ }
+
+ result[option.value] = Array.from(required);
+ }
+
+ return result;
+}
+
+function matchesEnumCondition(
+ conditional: JSONSchemaConditional,
+ enumKey: string,
+ value: string,
+) {
+ const conditionProps = conditional.if?.properties;
+ const constValue = conditionProps?.[enumKey]?.const;
+ if (constValue === undefined || constValue === null) return false;
+ return String(constValue) === value;
+}
diff --git a/web-common/src/features/templates/schemas/athena.ts b/web-common/src/features/templates/schemas/athena.ts
new file mode 100644
index 00000000000..cd6dd3719d9
--- /dev/null
+++ b/web-common/src/features/templates/schemas/athena.ts
@@ -0,0 +1,34 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const athenaSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ aws_access_key_id: {
+ type: "string",
+ title: "AWS access key ID",
+ description: "AWS access key ID used to authenticate to Athena",
+ "x-placeholder": "your_access_key_id",
+ "x-secret": true,
+ },
+ aws_secret_access_key: {
+ type: "string",
+ title: "AWS secret access key",
+ description: "AWS secret access key paired with the access key ID",
+ "x-placeholder": "your_secret_access_key",
+ "x-secret": true,
+ },
+ output_location: {
+ type: "string",
+ title: "S3 output location",
+ description:
+ "S3 URI where Athena should write query results (e.g., s3://bucket/path/)",
+ pattern: "^s3://.+",
+ errorMessage: {
+ pattern: "Must be an S3 URI (e.g., s3://bucket/path/)",
+ },
+ "x-placeholder": "s3://bucket-name/path/",
+ },
+ },
+ required: ["aws_access_key_id", "aws_secret_access_key", "output_location"],
+};
diff --git a/web-common/src/features/templates/schemas/azure.ts b/web-common/src/features/templates/schemas/azure.ts
new file mode 100644
index 00000000000..ac3146ca864
--- /dev/null
+++ b/web-common/src/features/templates/schemas/azure.ts
@@ -0,0 +1,108 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const azureSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ auth_method: {
+ type: "string",
+ title: "Authentication method",
+ enum: ["connection_string", "account_key", "sas_token", "public"],
+ default: "connection_string",
+ description: "Choose how to authenticate to Azure Blob Storage",
+ "x-display": "radio",
+ "x-enum-labels": [
+ "Connection String",
+ "Storage Account Key",
+ "SAS Token",
+ "Public",
+ ],
+ "x-enum-descriptions": [
+ "Provide a full Azure Storage connection string.",
+ "Provide the storage account name and access key.",
+ "Provide the storage account name and SAS token.",
+ "Access publicly readable blobs without credentials.",
+ ],
+ "x-grouped-fields": {
+ connection_string: ["azure_storage_connection_string"],
+ account_key: ["azure_storage_account", "azure_storage_key"],
+ sas_token: ["azure_storage_account", "azure_storage_sas_token"],
+ public: [],
+ },
+ "x-step": "connector",
+ },
+ azure_storage_connection_string: {
+ type: "string",
+ title: "Connection string",
+ description: "Paste an Azure Storage connection string",
+ "x-placeholder": "Enter Azure storage connection string",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "connection_string" },
+ },
+ azure_storage_account: {
+ type: "string",
+ title: "Storage account",
+ description: "The name of the Azure storage account",
+ "x-placeholder": "Enter Azure storage account",
+ "x-step": "connector",
+ "x-visible-if": { auth_method: ["account_key", "sas_token"] },
+ },
+ azure_storage_key: {
+ type: "string",
+ title: "Access key",
+ description: "Primary or secondary access key for the storage account",
+ "x-placeholder": "Enter Azure storage access key",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "account_key" },
+ },
+ azure_storage_sas_token: {
+ type: "string",
+ title: "SAS token",
+ description:
+ "Shared Access Signature token for the storage account (starting with ?sv=)",
+ "x-placeholder": "Enter Azure SAS token",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "sas_token" },
+ },
+ path: {
+ type: "string",
+ title: "Blob URI",
+ description:
+ "URI to the Azure blob container or directory (e.g., https://.blob.core.windows.net/container)",
+ pattern: "^azure://.+",
+ errorMessage: {
+ pattern: "Must be an Azure URI (e.g. azure://container/path)",
+ },
+ "x-placeholder": "azure://container/path",
+ "x-step": "source",
+ },
+ name: {
+ type: "string",
+ title: "Model name",
+ description: "Name for the source model",
+ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "x-placeholder": "my_model",
+ "x-step": "source",
+ },
+ },
+ required: ["path", "name"],
+ allOf: [
+ {
+ if: { properties: { auth_method: { const: "connection_string" } } },
+ then: { required: ["azure_storage_connection_string"] },
+ },
+ {
+ if: { properties: { auth_method: { const: "account_key" } } },
+ then: { required: ["azure_storage_account", "azure_storage_key"] },
+ },
+ {
+ if: { properties: { auth_method: { const: "sas_token" } } },
+ then: {
+ required: ["azure_storage_account", "azure_storage_sas_token"],
+ },
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/bigquery.ts b/web-common/src/features/templates/schemas/bigquery.ts
new file mode 100644
index 00000000000..6930a9698d6
--- /dev/null
+++ b/web-common/src/features/templates/schemas/bigquery.ts
@@ -0,0 +1,26 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const bigquerySchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ google_application_credentials: {
+ type: "string",
+ title: "GCP credentials",
+ description: "Service account JSON (uploaded or pasted)",
+ format: "file",
+ "x-display": "file",
+ "x-accept": ".json",
+ "x-secret": true,
+ },
+ project_id: {
+ type: "string",
+ title: "Project ID",
+ description: "Google Cloud project ID to use for queries",
+ "x-placeholder": "my-project",
+ "x-hint":
+ "If empty, Rill will use the project ID from your credentials when available.",
+ },
+ },
+ required: ["google_application_credentials"],
+};
diff --git a/web-common/src/features/templates/schemas/clickhouse.ts b/web-common/src/features/templates/schemas/clickhouse.ts
new file mode 100644
index 00000000000..ad5061a9eeb
--- /dev/null
+++ b/web-common/src/features/templates/schemas/clickhouse.ts
@@ -0,0 +1,130 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const clickhouseSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ connector_type: {
+ type: "string",
+ title: "Connection type",
+ enum: ["self-hosted", "clickhouse-cloud", "rill-managed"],
+ default: "self-hosted",
+ "x-display": "radio",
+ "x-enum-labels": [
+ "Self-hosted ClickHouse",
+ "ClickHouse Cloud",
+ "Rill-managed ClickHouse",
+ ],
+ "x-step": "connector",
+ },
+ dsn: {
+ type: "string",
+ title: "Connection string",
+ description:
+ "DSN connection string (use instead of individual host/port/user settings)",
+ "x-placeholder":
+ "clickhouse://localhost:9000?username=default&password=password",
+ "x-step": "connector",
+ },
+ managed: {
+ type: "boolean",
+ title: "Managed",
+ description:
+ "Use a managed ClickHouse instance (handled automatically by Rill)",
+ default: false,
+ "x-step": "connector",
+ },
+ host: {
+ type: "string",
+ title: "Host",
+ description: "Hostname or IP address of the ClickHouse server",
+ "x-placeholder":
+ "your-instance.clickhouse.cloud or your.clickhouse.server.com",
+ "x-hint":
+ "Your ClickHouse hostname (e.g., your-instance.clickhouse.cloud or your-server.com)",
+ "x-step": "connector",
+ },
+ port: {
+ type: "string",
+ title: "Port",
+ description: "Port number of the ClickHouse server",
+ pattern: "^\\d+$",
+ errorMessage: { pattern: "Port must be a number" },
+ default: "9000",
+ "x-placeholder": "9000",
+ "x-step": "connector",
+ },
+ username: {
+ type: "string",
+ title: "Username",
+ description: "Username to connect to the ClickHouse server",
+ default: "default",
+ "x-placeholder": "default",
+ "x-step": "connector",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description: "Password to connect to the ClickHouse server",
+ "x-placeholder": "Database password",
+ "x-secret": true,
+ "x-step": "connector",
+ },
+ database: {
+ type: "string",
+ title: "Database",
+ description: "Name of the ClickHouse database to connect to",
+ default: "default",
+ "x-placeholder": "default",
+ "x-step": "connector",
+ },
+ cluster: {
+ type: "string",
+ title: "Cluster",
+ description:
+ "Cluster name. If set, models are created as distributed tables.",
+ "x-placeholder": "Cluster name",
+ "x-step": "connector",
+ },
+ ssl: {
+ type: "boolean",
+ title: "SSL",
+ description: "Use SSL to connect to the ClickHouse server",
+ default: true,
+ "x-step": "connector",
+ },
+ },
+ required: ["connector_type"],
+ allOf: [
+ {
+ if: { properties: { connector_type: { const: "rill-managed" } } },
+ then: {
+ required: ["managed"],
+ properties: {
+ managed: { const: true },
+ },
+ },
+ },
+ {
+ if: { properties: { connector_type: { const: "self-hosted" } } },
+ then: {
+ required: ["host", "username"],
+ properties: {
+ managed: { const: false },
+ ssl: { default: true },
+ },
+ },
+ },
+ {
+ if: { properties: { connector_type: { const: "clickhouse-cloud" } } },
+ then: {
+ required: ["host", "username", "ssl"],
+ properties: {
+ managed: { const: false },
+ port: { default: "8443" },
+ ssl: { const: true },
+ },
+ },
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/druid.ts b/web-common/src/features/templates/schemas/druid.ts
new file mode 100644
index 00000000000..c2011829bc2
--- /dev/null
+++ b/web-common/src/features/templates/schemas/druid.ts
@@ -0,0 +1,61 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const druidSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ dsn: {
+ type: "string",
+ title: "Connection string",
+ description:
+ "Full Druid SQL/Avatica endpoint, e.g. https://host:8888/druid/v2/sql/avatica-protobuf?authentication=BASIC&avaticaUser=user&avaticaPassword=pass",
+ "x-placeholder":
+ "https://example.com/druid/v2/sql/avatica-protobuf?authentication=BASIC&avaticaUser=user&avaticaPassword=pass",
+ "x-secret": true,
+ },
+ host: {
+ type: "string",
+ title: "Host",
+ description: "Druid host or IP",
+ "x-placeholder": "localhost",
+ },
+ port: {
+ type: "string",
+ title: "Port",
+ description: "Druid port",
+ pattern: "^\\d+$",
+ errorMessage: { pattern: "Port must be a number" },
+ "x-placeholder": "8888",
+ },
+ username: {
+ type: "string",
+ title: "Username",
+ description: "Druid username",
+ "x-placeholder": "default",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description: "Druid password",
+ "x-placeholder": "password",
+ "x-secret": true,
+ },
+ ssl: {
+ type: "boolean",
+ title: "SSL",
+ description: "Use SSL for the connection",
+ default: true,
+ },
+ },
+ required: [],
+ oneOf: [
+ {
+ title: "Use connection string",
+ required: ["dsn"],
+ },
+ {
+ title: "Use individual parameters",
+ required: ["host", "ssl"],
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/duckdb.ts b/web-common/src/features/templates/schemas/duckdb.ts
new file mode 100644
index 00000000000..3e893aa79f3
--- /dev/null
+++ b/web-common/src/features/templates/schemas/duckdb.ts
@@ -0,0 +1,15 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const duckdbSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ title: "Path",
+ description: "Path to external DuckDB database",
+ "x-placeholder": "/path/to/main.db",
+ },
+ },
+ required: ["path"],
+};
diff --git a/web-common/src/features/templates/schemas/gcs.ts b/web-common/src/features/templates/schemas/gcs.ts
new file mode 100644
index 00000000000..4474bff30d7
--- /dev/null
+++ b/web-common/src/features/templates/schemas/gcs.ts
@@ -0,0 +1,87 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const gcsSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ auth_method: {
+ type: "string",
+ title: "Authentication method",
+ enum: ["credentials", "hmac", "public"],
+ default: "credentials",
+ description: "Choose how to authenticate to GCS",
+ "x-display": "radio",
+ "x-enum-labels": ["GCP credentials", "HMAC keys", "Public"],
+ "x-enum-descriptions": [
+ "Upload a JSON key file for a service account with GCS access.",
+ "Use HMAC access key and secret for S3-compatible authentication.",
+ "Access publicly readable buckets without credentials.",
+ ],
+ "x-grouped-fields": {
+ credentials: ["google_application_credentials"],
+ hmac: ["key_id", "secret"],
+ public: [],
+ },
+ "x-step": "connector",
+ },
+ google_application_credentials: {
+ type: "string",
+ title: "Service account key",
+ description:
+ "Upload a JSON key file for a service account with GCS access.",
+ format: "file",
+ "x-display": "file",
+ "x-accept": ".json",
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "credentials" },
+ },
+ key_id: {
+ type: "string",
+ title: "Access Key ID",
+ description: "HMAC access key ID for S3-compatible authentication",
+ "x-placeholder": "Enter your HMAC access key ID",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "hmac" },
+ },
+ secret: {
+ type: "string",
+ title: "Secret Access Key",
+ description: "HMAC secret access key for S3-compatible authentication",
+ "x-placeholder": "Enter your HMAC secret access key",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "hmac" },
+ },
+ path: {
+ type: "string",
+ title: "GCS URI",
+ description: "Path to your GCS bucket or prefix",
+ pattern: "^gs://[^/]+(/.*)?$",
+ errorMessage: {
+ pattern: "Must be a GS URI (e.g. gs://bucket/path)",
+ },
+ "x-placeholder": "gs://bucket/path",
+ "x-step": "source",
+ },
+ name: {
+ type: "string",
+ title: "Model name",
+ description: "Name for the source model",
+ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "x-placeholder": "my_model",
+ "x-step": "source",
+ },
+ },
+ required: ["path", "name"],
+ allOf: [
+ {
+ if: { properties: { auth_method: { const: "credentials" } } },
+ then: { required: ["google_application_credentials"] },
+ },
+ {
+ if: { properties: { auth_method: { const: "hmac" } } },
+ then: { required: ["key_id", "secret"] },
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/https.ts b/web-common/src/features/templates/schemas/https.ts
new file mode 100644
index 00000000000..157b76b3eff
--- /dev/null
+++ b/web-common/src/features/templates/schemas/https.ts
@@ -0,0 +1,27 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const httpsSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ title: "Path",
+ description: "HTTP/HTTPS URL to the remote file",
+ pattern: "^https?://.+",
+ errorMessage: {
+ pattern: "Path must start with http:// or https://",
+ },
+ "x-placeholder": "https://example.com/file.csv",
+ "x-step": "source",
+ },
+ name: {
+ type: "string",
+ title: "Source name",
+ description: "Name of the source",
+ "x-placeholder": "my_new_source",
+ "x-step": "source",
+ },
+ },
+ required: ["path", "name"],
+};
diff --git a/web-common/src/features/templates/schemas/local_file.ts b/web-common/src/features/templates/schemas/local_file.ts
new file mode 100644
index 00000000000..2fec6b68784
--- /dev/null
+++ b/web-common/src/features/templates/schemas/local_file.ts
@@ -0,0 +1,23 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const localFileSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ title: "Path",
+ description: "Local file path or glob (relative to project root)",
+ "x-placeholder": "data/*.parquet",
+ "x-step": "source",
+ },
+ name: {
+ type: "string",
+ title: "Source name",
+ description: "Name for the source",
+ "x-placeholder": "my_new_source",
+ "x-step": "source",
+ },
+ },
+ required: ["path", "name"],
+};
diff --git a/web-common/src/features/templates/schemas/motherduck.ts b/web-common/src/features/templates/schemas/motherduck.ts
new file mode 100644
index 00000000000..7597ae50e4e
--- /dev/null
+++ b/web-common/src/features/templates/schemas/motherduck.ts
@@ -0,0 +1,28 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const motherduckSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ title: "Path",
+ description: "MotherDuck database path (prefix with md:)",
+ "x-placeholder": "md:my_db",
+ },
+ token: {
+ type: "string",
+ title: "Token",
+ description: "MotherDuck token",
+ "x-placeholder": "your_motherduck_token",
+ "x-secret": true,
+ },
+ schema_name: {
+ type: "string",
+ title: "Schema name",
+ description: "Default schema to use",
+ "x-placeholder": "main",
+ },
+ },
+ required: ["path", "token", "schema_name"],
+};
diff --git a/web-common/src/features/templates/schemas/mysql.ts b/web-common/src/features/templates/schemas/mysql.ts
new file mode 100644
index 00000000000..5b8ce24fc25
--- /dev/null
+++ b/web-common/src/features/templates/schemas/mysql.ts
@@ -0,0 +1,86 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const mysqlSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ dsn: {
+ type: "string",
+ title: "MySQL connection string",
+ description:
+ "Full DSN, e.g. mysql://user:password@host:3306/database?ssl-mode=REQUIRED",
+ "x-placeholder": "mysql://user:password@host:3306/database",
+ "x-secret": true,
+ "x-hint":
+ "Use DSN or fill host/user/password/database below (not both at once).",
+ },
+ host: {
+ type: "string",
+ title: "Host",
+ description: "MySQL server hostname or IP",
+ "x-placeholder": "localhost",
+ },
+ port: {
+ type: "string",
+ title: "Port",
+ description: "MySQL server port",
+ pattern: "^\\d+$",
+ errorMessage: { pattern: "Port must be a number" },
+ default: "3306",
+ "x-placeholder": "3306",
+ },
+ database: {
+ type: "string",
+ title: "Database",
+ description: "Database name",
+ "x-placeholder": "my_database",
+ },
+ user: {
+ type: "string",
+ title: "Username",
+ description: "MySQL user",
+ "x-placeholder": "mysql",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description: "MySQL password",
+ "x-placeholder": "your_password",
+ "x-secret": true,
+ },
+ "ssl-mode": {
+ type: "string",
+ title: "SSL mode",
+ description: "Use DISABLED, PREFERRED, or REQUIRED",
+ enum: ["DISABLED", "PREFERRED", "REQUIRED"],
+ "x-placeholder": "PREFERRED",
+ },
+ log_queries: {
+ type: "boolean",
+ title: "Log queries",
+ description: "Enable logging of SQL queries (for debugging)",
+ default: false,
+ },
+ },
+ required: [],
+ oneOf: [
+ {
+ title: "Use DSN",
+ required: ["dsn"],
+ not: {
+ anyOf: [
+ { required: ["host"] },
+ { required: ["database"] },
+ { required: ["user"] },
+ { required: ["password"] },
+ { required: ["port"] },
+ { required: ["ssl-mode"] },
+ ],
+ },
+ },
+ {
+ title: "Use individual parameters",
+ required: ["host", "database", "user"],
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/pinot.ts b/web-common/src/features/templates/schemas/pinot.ts
new file mode 100644
index 00000000000..e437ed9101e
--- /dev/null
+++ b/web-common/src/features/templates/schemas/pinot.ts
@@ -0,0 +1,75 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const pinotSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ dsn: {
+ type: "string",
+ title: "Connection string",
+ description:
+ "Full Pinot connection string, e.g. http(s)://user:password@broker:8000?controller=host:9000",
+ "x-placeholder":
+ "https://username:password@localhost:8000?controller=localhost:9000",
+ "x-secret": true,
+ },
+ broker_host: {
+ type: "string",
+ title: "Broker host",
+ description: "Pinot broker host",
+ "x-placeholder": "localhost",
+ },
+ broker_port: {
+ type: "string",
+ title: "Broker port",
+ description: "Pinot broker port",
+ pattern: "^\\d+$",
+ errorMessage: { pattern: "Port must be a number" },
+ "x-placeholder": "8000",
+ },
+ controller_host: {
+ type: "string",
+ title: "Controller host",
+ description: "Pinot controller host",
+ "x-placeholder": "localhost",
+ },
+ controller_port: {
+ type: "string",
+ title: "Controller port",
+ description: "Pinot controller port",
+ pattern: "^\\d+$",
+ errorMessage: { pattern: "Port must be a number" },
+ "x-placeholder": "9000",
+ },
+ username: {
+ type: "string",
+ title: "Username",
+ description: "Pinot username",
+ "x-placeholder": "default",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description: "Pinot password",
+ "x-placeholder": "password",
+ "x-secret": true,
+ },
+ ssl: {
+ type: "boolean",
+ title: "SSL",
+ description: "Use SSL",
+ default: true,
+ },
+ },
+ required: [],
+ oneOf: [
+ {
+ title: "Use connection string",
+ required: ["dsn"],
+ },
+ {
+ title: "Use individual parameters",
+ required: ["broker_host", "controller_host", "ssl"],
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/postgres.ts b/web-common/src/features/templates/schemas/postgres.ts
new file mode 100644
index 00000000000..8ab2626bb8e
--- /dev/null
+++ b/web-common/src/features/templates/schemas/postgres.ts
@@ -0,0 +1,109 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const postgresSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ dsn: {
+ type: "string",
+ title: "Postgres connection string",
+ description:
+ "Full DSN, e.g. postgresql://user:password@host:5432/dbname?sslmode=require",
+ "x-placeholder": "postgresql://postgres:postgres@localhost:5432/postgres",
+ "x-secret": true,
+ "x-hint":
+ "Use a DSN or provide host/user/password/dbname below (but not both).",
+ },
+ database_url: {
+ type: "string",
+ title: "Database URL",
+ description: "Alternative DSN field (same as dsn)",
+ "x-placeholder": "postgresql://postgres:postgres@localhost:5432/postgres",
+ "x-secret": true,
+ },
+ host: {
+ type: "string",
+ title: "Host",
+ description: "Postgres server hostname or IP",
+ "x-placeholder": "localhost",
+ },
+ port: {
+ type: "string",
+ title: "Port",
+ description: "Postgres server port",
+ pattern: "^\\d+$",
+ errorMessage: { pattern: "Port must be a number" },
+ default: "5432",
+ "x-placeholder": "5432",
+ },
+ user: {
+ type: "string",
+ title: "Username",
+ description: "Postgres user",
+ "x-placeholder": "postgres",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description: "Postgres password",
+ "x-placeholder": "your_password",
+ "x-secret": true,
+ },
+ dbname: {
+ type: "string",
+ title: "Database",
+ description: "Database name",
+ "x-placeholder": "postgres",
+ },
+ sslmode: {
+ type: "string",
+ title: "SSL mode",
+ description: "Use disable, allow, prefer, require",
+ enum: ["disable", "allow", "prefer", "require"],
+ "x-placeholder": "require",
+ },
+ log_queries: {
+ type: "boolean",
+ title: "Log queries",
+ description: "Enable logging of SQL queries (for debugging)",
+ default: false,
+ },
+ },
+ required: [],
+ oneOf: [
+ {
+ title: "Use DSN",
+ required: ["dsn"],
+ not: {
+ anyOf: [
+ { required: ["database_url"] },
+ { required: ["host"] },
+ { required: ["port"] },
+ { required: ["user"] },
+ { required: ["password"] },
+ { required: ["dbname"] },
+ { required: ["sslmode"] },
+ ],
+ },
+ },
+ {
+ title: "Use Database URL",
+ required: ["database_url"],
+ not: {
+ anyOf: [
+ { required: ["dsn"] },
+ { required: ["host"] },
+ { required: ["port"] },
+ { required: ["user"] },
+ { required: ["password"] },
+ { required: ["dbname"] },
+ { required: ["sslmode"] },
+ ],
+ },
+ },
+ {
+ title: "Use individual parameters",
+ required: ["host", "user", "dbname"],
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/redshift.ts b/web-common/src/features/templates/schemas/redshift.ts
new file mode 100644
index 00000000000..f2e1ade5536
--- /dev/null
+++ b/web-common/src/features/templates/schemas/redshift.ts
@@ -0,0 +1,48 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const redshiftSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ aws_access_key_id: {
+ type: "string",
+ title: "AWS access key ID",
+ description: "AWS access key ID",
+ "x-placeholder": "your_access_key_id",
+ "x-secret": true,
+ },
+ aws_secret_access_key: {
+ type: "string",
+ title: "AWS secret access key",
+ description: "AWS secret access key",
+ "x-placeholder": "your_secret_access_key",
+ "x-secret": true,
+ },
+ region: {
+ type: "string",
+ title: "AWS region",
+ description: "AWS region (e.g. us-east-1)",
+ "x-placeholder": "us-east-1",
+ },
+ database: {
+ type: "string",
+ title: "Database",
+ description: "Redshift database name",
+ "x-placeholder": "dev",
+ },
+ workgroup: {
+ type: "string",
+ title: "Workgroup",
+ description: "Redshift Serverless workgroup name",
+ "x-placeholder": "default",
+ },
+ cluster_identifier: {
+ type: "string",
+ title: "Cluster identifier",
+ description:
+ "Redshift cluster identifier (use when not using serverless)",
+ "x-placeholder": "redshift-cluster-1",
+ },
+ },
+ required: ["aws_access_key_id", "aws_secret_access_key", "database"],
+};
diff --git a/web-common/src/features/templates/schemas/s3.ts b/web-common/src/features/templates/schemas/s3.ts
new file mode 100644
index 00000000000..8e9799cbc64
--- /dev/null
+++ b/web-common/src/features/templates/schemas/s3.ts
@@ -0,0 +1,105 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const s3Schema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ auth_method: {
+ type: "string",
+ title: "Authentication method",
+ description: "Choose how to authenticate to S3",
+ enum: ["access_keys", "public"],
+ default: "access_keys",
+ "x-display": "radio",
+ "x-enum-labels": ["Access keys", "Public"],
+ "x-enum-descriptions": [
+ "Use AWS access key ID and secret access key.",
+ "Access publicly readable buckets without credentials.",
+ ],
+ "x-grouped-fields": {
+ access_keys: [
+ "aws_access_key_id",
+ "aws_secret_access_key",
+ "region",
+ "endpoint",
+ "aws_role_arn",
+ ],
+ public: [],
+ },
+ "x-step": "connector",
+ },
+ aws_access_key_id: {
+ type: "string",
+ title: "Access Key ID",
+ description: "AWS access key ID for the bucket",
+ "x-placeholder": "Enter AWS access key ID",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "access_keys" },
+ },
+ aws_secret_access_key: {
+ type: "string",
+ title: "Secret Access Key",
+ description: "AWS secret access key for the bucket",
+ "x-placeholder": "Enter AWS secret access key",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "access_keys" },
+ },
+ region: {
+ type: "string",
+ title: "Region",
+ description:
+ "Rill uses your default AWS region unless you set it explicitly.",
+ "x-placeholder": "us-east-1",
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "access_keys" },
+ },
+ endpoint: {
+ type: "string",
+ title: "Endpoint",
+ description:
+ "Override the S3 endpoint (for S3-compatible services like R2/MinIO).",
+ "x-placeholder": "https://s3.example.com",
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "access_keys" },
+ },
+ aws_role_arn: {
+ type: "string",
+ title: "AWS Role ARN",
+ description: "AWS Role ARN to assume",
+ "x-placeholder": "arn:aws:iam::123456789012:role/MyRole",
+ "x-secret": true,
+ "x-step": "connector",
+ "x-visible-if": { auth_method: "access_keys" },
+ },
+ path: {
+ type: "string",
+ title: "S3 URI",
+ description: "Path to your S3 bucket or prefix",
+ pattern: "^s3://[^/]+(/.*)?$",
+ errorMessage: {
+ pattern: "Must be an S3 URI (e.g. s3://bucket/path)",
+ },
+ "x-placeholder": "s3://bucket/path",
+ "x-step": "source",
+ },
+ name: {
+ type: "string",
+ title: "Model name",
+ description: "Name for the source model",
+ pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
+ "x-placeholder": "my_model",
+ "x-step": "source",
+ },
+ },
+ required: ["path", "name"],
+ allOf: [
+ {
+ if: { properties: { auth_method: { const: "access_keys" } } },
+ then: {
+ required: ["aws_access_key_id", "aws_secret_access_key"],
+ },
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/salesforce.ts b/web-common/src/features/templates/schemas/salesforce.ts
new file mode 100644
index 00000000000..c60092f32fe
--- /dev/null
+++ b/web-common/src/features/templates/schemas/salesforce.ts
@@ -0,0 +1,94 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const salesforceSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ soql: {
+ type: "string",
+ title: "SOQL",
+ description: "SOQL query to extract data",
+ "x-display": "textarea",
+ "x-placeholder": "SELECT Id, Name FROM Opportunity",
+ "x-step": "source",
+ },
+ sobject: {
+ type: "string",
+ title: "SObject",
+ description: "Salesforce object to query",
+ "x-placeholder": "Opportunity",
+ "x-step": "source",
+ },
+ queryAll: {
+ type: "boolean",
+ title: "Query all",
+ description: "Include deleted and archived records",
+ default: false,
+ "x-step": "source",
+ },
+ username: {
+ type: "string",
+ title: "Username",
+ description: "Salesforce username (usually an email)",
+ "x-placeholder": "user@example.com",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description:
+ "Salesforce password, optionally followed by security token if required",
+ "x-placeholder": "your_password_or_password+token",
+ "x-secret": true,
+ },
+ key: {
+ type: "string",
+ title: "JWT private key",
+ description: "PEM-formatted private key for JWT auth",
+ "x-display": "textarea",
+ "x-secret": true,
+ },
+ client_id: {
+ type: "string",
+ title: "Connected App Client ID",
+ description: "Client ID (consumer key) for JWT auth",
+ "x-placeholder": "Connected App client ID",
+ },
+ endpoint: {
+ type: "string",
+ title: "Login endpoint",
+ description:
+ "Salesforce login URL (e.g., login.salesforce.com or test.salesforce.com)",
+ "x-placeholder": "login.salesforce.com",
+ },
+ name: {
+ type: "string",
+ title: "Source name",
+ description: "Name for the source",
+ "x-placeholder": "my_new_source",
+ "x-step": "source",
+ },
+ },
+ required: ["soql", "sobject", "name"],
+ allOf: [
+ {
+ if: {
+ properties: {
+ key: { const: "" },
+ },
+ },
+ then: {
+ required: ["username", "password", "endpoint"],
+ },
+ },
+ {
+ if: {
+ properties: {
+ key: { const: undefined },
+ },
+ },
+ then: {
+ required: ["username", "password", "endpoint"],
+ },
+ },
+ ],
+};
diff --git a/web-common/src/features/templates/schemas/snowflake.ts b/web-common/src/features/templates/schemas/snowflake.ts
new file mode 100644
index 00000000000..18dec9a92b7
--- /dev/null
+++ b/web-common/src/features/templates/schemas/snowflake.ts
@@ -0,0 +1,90 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const snowflakeSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ dsn: {
+ type: "string",
+ title: "Snowflake connection string",
+ description:
+ "Full Snowflake DSN, e.g. @//?warehouse=&role=",
+ "x-placeholder":
+ "@//?warehouse=&role=",
+ "x-secret": true,
+ "x-hint":
+ "Use a full DSN or fill the fields below (not both). Include authenticator and privateKey for JWT if needed.",
+ },
+ account: {
+ type: "string",
+ title: "Account identifier",
+ description:
+ "Snowflake account identifier (from your Snowflake URL, before .snowflakecomputing.com)",
+ "x-placeholder": "abc12345",
+ },
+ user: {
+ type: "string",
+ title: "Username",
+ description: "Snowflake username",
+ "x-placeholder": "your_username",
+ },
+ password: {
+ type: "string",
+ title: "Password",
+ description:
+ "Snowflake password (use JWT private key if password auth is disabled)",
+ "x-placeholder": "your_password",
+ "x-secret": true,
+ },
+ privateKey: {
+ type: "string",
+ title: "Private key (JWT)",
+ description:
+ "URL-safe base64 or PEM private key for SNOWFLAKE_JWT authenticator",
+ "x-display": "textarea",
+ "x-secret": true,
+ },
+ authenticator: {
+ type: "string",
+ title: "Authenticator",
+ description: "Override authenticator (e.g., SNOWFLAKE_JWT)",
+ "x-placeholder": "SNOWFLAKE_JWT",
+ },
+ database: {
+ type: "string",
+ title: "Database",
+ description: "Snowflake database",
+ "x-placeholder": "your_database",
+ },
+ schema: {
+ type: "string",
+ title: "Schema",
+ description: "Default schema",
+ "x-placeholder": "public",
+ },
+ warehouse: {
+ type: "string",
+ title: "Warehouse",
+ description: "Compute warehouse",
+ "x-placeholder": "your_warehouse",
+ },
+ role: {
+ type: "string",
+ title: "Role",
+ description: "Snowflake role",
+ "x-placeholder": "your_role",
+ },
+ parallel_fetch_limit: {
+ type: "number",
+ title: "Parallel fetch limit",
+ description: "Limit concurrent fetches (leave empty for default)",
+ },
+ log_queries: {
+ type: "boolean",
+ title: "Log queries",
+ description: "Enable SQL query logging (debugging)",
+ default: false,
+ },
+ },
+ required: [],
+};
diff --git a/web-common/src/features/templates/schemas/sqlite.ts b/web-common/src/features/templates/schemas/sqlite.ts
new file mode 100644
index 00000000000..039f8550f83
--- /dev/null
+++ b/web-common/src/features/templates/schemas/sqlite.ts
@@ -0,0 +1,30 @@
+import type { MultiStepFormSchema } from "./types";
+
+export const sqliteSchema: MultiStepFormSchema = {
+ $schema: "http://json-schema.org/draft-07/schema#",
+ type: "object",
+ properties: {
+ db: {
+ type: "string",
+ title: "Database file",
+ description: "Path to SQLite db file",
+ "x-placeholder": "/path/to/sqlite.db",
+ "x-step": "source",
+ },
+ table: {
+ type: "string",
+ title: "Table",
+ description: "SQLite table name",
+ "x-placeholder": "table",
+ "x-step": "source",
+ },
+ name: {
+ type: "string",
+ title: "Source name",
+ description: "Name of the source",
+ "x-placeholder": "my_new_source",
+ "x-step": "source",
+ },
+ },
+ required: ["db", "table", "name"],
+};
diff --git a/web-common/src/features/templates/schemas/types.ts b/web-common/src/features/templates/schemas/types.ts
new file mode 100644
index 00000000000..89cf0bc0720
--- /dev/null
+++ b/web-common/src/features/templates/schemas/types.ts
@@ -0,0 +1,68 @@
+export type JSONSchemaVisibleIfValue =
+ | string
+ | number
+ | boolean
+ | Array;
+
+export type JSONSchemaField = {
+ type?: "string" | "number" | "boolean" | "object";
+ title?: string;
+ description?: string;
+ enum?: Array;
+ const?: string | number | boolean;
+ default?: string | number | boolean;
+ pattern?: string;
+ format?: string;
+ errorMessage?: {
+ pattern?: string;
+ format?: string;
+ };
+ properties?: Record;
+ required?: string[];
+ "x-display"?: "radio" | "select" | "textarea" | "file";
+ "x-step"?: "connector" | "source";
+ "x-secret"?: boolean;
+ "x-visible-if"?: Record;
+ "x-enum-labels"?: string[];
+ "x-enum-descriptions"?: string[];
+ "x-placeholder"?: string;
+ "x-hint"?: string;
+ "x-accept"?: string;
+ /**
+ * Explicit grouping for radio/select options: maps an option value to the
+ * child field keys that should render beneath that option.
+ */
+ "x-grouped-fields"?: Record;
+ // Allow custom keywords such as errorMessage or future x-extensions.
+ [key: string]: unknown;
+};
+
+export type JSONSchemaCondition = {
+ properties?: Record;
+};
+
+export type JSONSchemaConstraint = {
+ required?: string[];
+ properties?: Record;
+ // Allow custom keywords or overrides in constraints
+ [key: string]: unknown;
+};
+
+export type JSONSchemaConditional = {
+ if?: JSONSchemaCondition;
+ then?: JSONSchemaConstraint;
+ else?: JSONSchemaConstraint;
+};
+
+export type JSONSchemaObject = {
+ $schema?: string;
+ type: "object";
+ title?: string;
+ description?: string;
+ properties?: Record;
+ required?: string[];
+ allOf?: JSONSchemaConditional[];
+ oneOf?: JSONSchemaConstraint[];
+};
+
+export type MultiStepFormSchema = JSONSchemaObject;
diff --git a/web-local/tests/connectors/multi-step-connector.spec.ts b/web-local/tests/connectors/multi-step-connector.spec.ts
new file mode 100644
index 00000000000..39abf31954d
--- /dev/null
+++ b/web-local/tests/connectors/multi-step-connector.spec.ts
@@ -0,0 +1,187 @@
+import { expect } from "@playwright/test";
+import { test } from "../setup/base";
+
+test.describe("Multi-step connector wrapper", () => {
+ test.use({ project: "Blank" });
+
+ test("GCS connector - renders connector step schema via wrapper", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+ // Choose a multi-step connector (GCS).
+ await page.locator("#gcs").click();
+ await page.waitForSelector('form[id*="gcs"]');
+
+ // Connector step should show connector preview and connector CTA.
+ await expect(page.getByText("Connector preview")).toBeVisible();
+ await expect(
+ page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Test and Connect" }),
+ ).toBeVisible();
+
+ // Auth method controls from the connector schema should render.
+ const hmacRadio = page.getByRole("radio", { name: "HMAC keys" });
+ await expect(hmacRadio).toBeVisible();
+ await expect(page.getByRole("radio", { name: "Public" })).toBeVisible();
+
+ // Select HMAC so its fields are rendered.
+ await hmacRadio.click();
+
+ // Connector step fields should be present, while source step fields should not yet render.
+ await expect(
+ page.getByRole("textbox", { name: "Access Key ID" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("textbox", { name: "Secret Access Key" }),
+ ).toBeVisible();
+ await expect(page.getByRole("textbox", { name: "GS URI" })).toHaveCount(0);
+ });
+
+ test("GCS connector - renders source step schema via wrapper", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+ // Choose a multi-step connector (GCS).
+ await page.locator("#gcs").click();
+ await page.waitForSelector('form[id*="gcs"]');
+
+ // Connector step visible with CTA.
+ await expect(page.getByText("Connector preview")).toBeVisible();
+ await expect(
+ page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Test and Connect" }),
+ ).toBeVisible();
+
+ // Switch to Public auth (no required fields) and continue via CTA.
+ await page.getByRole("radio", { name: "Public" }).click();
+ const connectorCta = page.getByRole("button", {
+ name: /Test and Connect|Continue/i,
+ });
+ await connectorCta.click();
+
+ // Source step should now render with source schema fields and CTA.
+ await expect(page.getByText("Model preview")).toBeVisible();
+ const sourceCta = page.getByRole("button", {
+ name: /Test and Add data|Importing data|Add data/i,
+ });
+ await expect(sourceCta).toBeVisible();
+
+ // Source fields should be present; connector-only auth fields should not be required to show.
+ await expect(page.getByRole("textbox", { name: "GCS URI" })).toBeVisible(
+ {},
+ );
+ await expect(
+ page.getByRole("textbox", { name: "Model name" }),
+ ).toBeVisible();
+ });
+
+ test("GCS connector - preserves auth selection across steps", async ({
+ page,
+ }) => {
+ const hmacKey = process.env.RILL_RUNTIME_GCS_TEST_HMAC_KEY;
+ const hmacSecret = process.env.RILL_RUNTIME_GCS_TEST_HMAC_SECRET;
+ if (!hmacKey || !hmacSecret) {
+ test.skip(
+ true,
+ "RILL_RUNTIME_GCS_TEST_HMAC_KEY or RILL_RUNTIME_GCS_TEST_HMAC_SECRET is not set",
+ );
+ }
+
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+ await page.locator("#gcs").click();
+ await page.waitForSelector('form[id*="gcs"]');
+
+ // Pick HMAC auth and fill required fields.
+ await page.getByRole("radio", { name: "HMAC keys" }).click();
+ await page.getByRole("textbox", { name: "Access Key ID" }).fill(hmacKey!);
+ await page
+ .getByRole("textbox", { name: "Secret Access Key" })
+ .fill(hmacSecret!);
+
+ // Submit connector step via CTA to transition to source step.
+ const connectorCta = page.getByRole("button", {
+ name: /Test and Connect|Continue/i,
+ });
+ await expect(connectorCta).toBeEnabled();
+ await connectorCta.click();
+ await expect(page.getByText("Model preview")).toBeVisible();
+
+ // Go back to connector step.
+ await page.getByRole("button", { name: "Back" }).click();
+
+ // Auth selection and values should persist.
+ await expect(page.getByText("Connector preview")).toBeVisible();
+ await expect(page.getByRole("radio", { name: "HMAC keys" })).toBeChecked({
+ timeout: 5000,
+ });
+ await expect(
+ page.getByRole("textbox", { name: "Access Key ID" }),
+ ).toHaveValue(hmacKey!);
+ await expect(
+ page.getByRole("textbox", { name: "Secret Access Key" }),
+ ).toHaveValue(hmacSecret!);
+ });
+
+ test("GCS connector - disables submit until auth requirements met", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+ await page.locator("#gcs").click();
+ await page.waitForSelector('form[id*="gcs"]');
+
+ const connectorCta = page.getByRole("button", {
+ name: /Test and Connect|Continue/i,
+ });
+
+ // Default auth is credentials (file upload); switch to HMAC to check required fields.
+ await page.getByRole("radio", { name: "HMAC keys" }).click();
+ await expect(connectorCta).toBeDisabled();
+
+ // Fill key only -> still disabled.
+ await page
+ .getByRole("textbox", { name: "Access Key ID" })
+ .fill("AKIA_TEST");
+ await expect(connectorCta).toBeDisabled();
+
+ // Fill secret -> enabled.
+ await page
+ .getByRole("textbox", { name: "Secret Access Key" })
+ .fill("SECRET");
+ await expect(connectorCta).toBeEnabled();
+ });
+
+ test("GCS connector - public auth option keeps submit enabled and allows advancing", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+
+ await page.locator("#gcs").click();
+ await page.waitForSelector('form[id*="gcs"]');
+
+ const connectorCta = page.getByRole("button", {
+ name: /Test and Connect|Continue/i,
+ });
+
+ // Switch to Public (no required fields) -> CTA should remain enabled and allow advancing.
+ await page.getByRole("radio", { name: "Public" }).click();
+ await expect(connectorCta).toBeEnabled();
+ await connectorCta.click();
+
+ // Should land on source step without needing connector fields.
+ await expect(page.getByText("Model preview")).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /Test and Add data|Add data/i }),
+ ).toBeVisible();
+ });
+});
diff --git a/web-local/tests/connectors/test-connection.spec.ts b/web-local/tests/connectors/test-connection.spec.ts
index e571c62d3bf..1ed49d8276c 100644
--- a/web-local/tests/connectors/test-connection.spec.ts
+++ b/web-local/tests/connectors/test-connection.spec.ts
@@ -4,6 +4,73 @@ import { test } from "../setup/base";
test.describe("Test Connection", () => {
test.use({ project: "Blank" });
+ test("Azure connector - auth method specific required fields", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+ await page.locator("#azure").click();
+ await page.waitForSelector('form[id*="azure"]');
+
+ const button = page.getByRole("dialog").getByRole("button", {
+ name: /(Test and Connect|Continue)/,
+ });
+
+ // Select Storage Account Key (default may be different) -> requires account + key.
+ await page.getByRole("radio", { name: "Storage Account Key" }).click();
+ await expect(button).toBeDisabled();
+
+ await page.getByRole("textbox", { name: "Storage account" }).fill("acct");
+ await expect(button).toBeDisabled();
+
+ await page.getByRole("textbox", { name: "Access key" }).fill("key");
+ await expect(button).toBeEnabled();
+
+ // Switch to Public (no required fields) -> button should stay enabled.
+ await page.getByRole("radio", { name: "Public" }).click();
+ await expect(button).toBeEnabled();
+
+ // Switch to Connection String -> requires connection string, so disabled until filled.
+ await page.getByRole("radio", { name: "Connection String" }).click();
+ await expect(button).toBeDisabled();
+ await page
+ .getByRole("textbox", { name: "Connection string" })
+ .fill("DefaultEndpointsProtocol=https;");
+ await expect(button).toBeEnabled();
+ });
+
+ test("S3 connector - auth method specific required fields", async ({
+ page,
+ }) => {
+ await page.getByRole("button", { name: "Add Asset" }).click();
+ await page.getByRole("menuitem", { name: "Add Data" }).click();
+ await page.locator("#s3").click();
+ await page.waitForSelector('form[id*="s3"]');
+
+ const button = page.getByRole("dialog").getByRole("button", {
+ name: /(Test and Connect|Continue)/,
+ });
+
+ // Default method is Access keys -> requires access key id + secret.
+ await expect(button).toBeDisabled();
+ await page
+ .getByRole("textbox", { name: "Access Key ID" })
+ .fill("AKIA_TEST");
+ await expect(button).toBeDisabled();
+ await page
+ .getByRole("textbox", { name: "Secret Access Key" })
+ .fill("SECRET");
+ await expect(button).toBeEnabled();
+
+ // Switch to Public (no required fields) -> button should stay enabled.
+ await page.getByRole("radio", { name: "Public" }).click();
+ await expect(button).toBeEnabled();
+
+ // Switch back to Access keys -> fields cleared, so disabled until refilled.
+ await page.getByRole("radio", { name: "Access keys" }).click();
+ await expect(button).toBeDisabled();
+ });
+
test("GCS connector - HMAC", async ({ page }) => {
// Skip test if environment variables are not set
if (