Skip to content

Commit 20f6c50

Browse files
committed
feat(HTTPS): Replace textarea headers with structured key-value input
Replace the textarea-based HTTP headers input with a dynamic key-value pair component. Users can now add/remove headers individually instead of typing raw "Header-Name: value" text. - New KeyValueInput.svelte component with add/remove rows and duplicate detection - Add "key-value" to x-display type union for form schema system - Wire KeyValueInput through SchemaField.svelte renderer - Update formatHeadersAsYamlMap to accept array of {key, value} objects - Initialize key-value form fields to empty arrays - Add wildcard support to schema button labels - Change HTTPS connector button from "Test and Connect" to "Continue" (Ping is a no-op) - Headers field is now optional in HTTPS schema
1 parent 2ac8ade commit 20f6c50

File tree

8 files changed

+212
-29
lines changed

8 files changed

+212
-29
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<script lang="ts">
2+
import { PlusIcon, XIcon } from "lucide-svelte";
3+
import { tick } from "svelte";
4+
import InputLabel from "./InputLabel.svelte";
5+
6+
export let id: string;
7+
export let label = "";
8+
export let hint: string | undefined = undefined;
9+
export let optional = false;
10+
export let value: Array<{ key: string; value: string }> = [];
11+
export let keyPlaceholder = "Header name";
12+
export let valuePlaceholder = "Value";
13+
14+
let keyInputs: HTMLInputElement[] = [];
15+
16+
// Defensive: coerce to array if value is undefined or wrong type
17+
$: entries = Array.isArray(value) ? value : [];
18+
$: duplicateKeys = findDuplicateKeys(entries);
19+
20+
function findDuplicateKeys(
21+
entries: Array<{ key: string; value: string }>,
22+
): Set<string> {
23+
const seen = new Set<string>();
24+
const dupes = new Set<string>();
25+
for (const entry of entries) {
26+
const k = entry.key.trim();
27+
if (!k) continue;
28+
if (seen.has(k)) dupes.add(k);
29+
seen.add(k);
30+
}
31+
return dupes;
32+
}
33+
34+
async function addRow() {
35+
value = [...value, { key: "", value: "" }];
36+
await tick();
37+
keyInputs[value.length - 1]?.focus();
38+
}
39+
40+
function removeRow(index: number) {
41+
value = value.filter((_, i) => i !== index);
42+
}
43+
44+
function updateKey(index: number, newKey: string) {
45+
value[index] = { ...value[index], key: newKey };
46+
value = value;
47+
}
48+
49+
function updateValue(index: number, newValue: string) {
50+
value[index] = { ...value[index], value: newValue };
51+
value = value;
52+
}
53+
</script>
54+
55+
<div class="flex flex-col gap-y-1">
56+
{#if label}
57+
<InputLabel {id} {label} {hint} {optional} />
58+
{/if}
59+
60+
{#each entries as entry, i (i)}
61+
<div class="flex items-center gap-1.5">
62+
<div class="kv-input-wrapper flex-1">
63+
<input
64+
bind:this={keyInputs[i]}
65+
type="text"
66+
placeholder={keyPlaceholder}
67+
value={entry.key}
68+
on:input={(e) => updateKey(i, e.currentTarget.value)}
69+
aria-label="Header name {i + 1}"
70+
class="kv-input"
71+
/>
72+
</div>
73+
<div class="kv-input-wrapper flex-1">
74+
<input
75+
type="text"
76+
placeholder={valuePlaceholder}
77+
value={entry.value}
78+
on:input={(e) => updateValue(i, e.currentTarget.value)}
79+
aria-label="Header value {i + 1}"
80+
class="kv-input"
81+
/>
82+
</div>
83+
<button
84+
type="button"
85+
class="remove-button"
86+
on:click={() => removeRow(i)}
87+
aria-label="Remove header {i + 1}"
88+
>
89+
<XIcon size="14px" />
90+
</button>
91+
</div>
92+
{#if duplicateKeys.has(entry.key.trim())}
93+
<div class="text-xs text-amber-600">
94+
Duplicate key "{entry.key.trim()}" — last value wins
95+
</div>
96+
{/if}
97+
{/each}
98+
99+
<button type="button" class="add-button" on:click={addRow}>
100+
<PlusIcon size="14px" />
101+
Add header
102+
</button>
103+
</div>
104+
105+
<style lang="postcss">
106+
.kv-input-wrapper {
107+
@apply border rounded-[2px] bg-input px-2;
108+
@apply flex items-center;
109+
height: 30px;
110+
}
111+
112+
.kv-input-wrapper:focus-within {
113+
@apply border-primary-500 ring-2 ring-primary-100;
114+
}
115+
116+
.kv-input {
117+
@apply bg-transparent outline-none border-0;
118+
@apply text-xs placeholder-fg-muted;
119+
@apply w-full h-full;
120+
}
121+
122+
.remove-button {
123+
@apply text-fg-muted hover:text-fg-primary;
124+
@apply flex-none flex items-center justify-center;
125+
@apply cursor-pointer;
126+
width: 24px;
127+
height: 30px;
128+
}
129+
130+
.add-button {
131+
@apply flex items-center gap-1;
132+
@apply text-xs text-primary-500 hover:text-primary-600 font-medium;
133+
@apply cursor-pointer w-fit mt-0.5;
134+
}
135+
</style>

web-common/src/features/connectors/code-utils.ts

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,24 +40,39 @@ sql: {{ sql }}{{ dev_section }}
4040
}
4141

4242
/**
43-
* Parse a multi-line "Header-Name: value" string into a YAML map block.
44-
* Returns an empty string when there are no valid entries.
43+
* Convert header entries into a YAML map block.
44+
* Accepts an array of {key, value} objects (new key-value input) or a legacy
45+
* multi-line "Header-Name: value" string. Returns empty string when there are
46+
* no valid entries.
4547
*/
46-
function formatHeadersAsYamlMap(value: string): string {
47-
const lines = value
48-
.split("\n")
49-
.map((line) => line.trim())
50-
.filter((line) => line.includes(":"));
51-
if (lines.length === 0) return "";
52-
const entries = lines.map((line) => {
53-
const idx = line.indexOf(":");
54-
const k = line.substring(0, idx).trim().replace(/^"|"$/g, "");
55-
const v = line
56-
.substring(idx + 1)
57-
.trim()
58-
.replace(/^"|"$/g, "");
59-
return ` "${k}": "${v}"`;
60-
});
48+
function formatHeadersAsYamlMap(
49+
value: Array<{ key: string; value: string }> | string,
50+
): string {
51+
if (typeof value === "string") {
52+
// Legacy textarea format: parse "Key: Value" lines
53+
const lines = value
54+
.split("\n")
55+
.map((line) => line.trim())
56+
.filter((line) => line.includes(":"));
57+
if (lines.length === 0) return "";
58+
const entries = lines.map((line) => {
59+
const idx = line.indexOf(":");
60+
const k = line.substring(0, idx).trim().replace(/^"|"$/g, "");
61+
const v = line
62+
.substring(idx + 1)
63+
.trim()
64+
.replace(/^"|"$/g, "");
65+
return ` "${k}": "${v}"`;
66+
});
67+
return `headers:\n${entries.join("\n")}`;
68+
}
69+
70+
// Array of {key, value} objects from key-value input
71+
const valid = value.filter((e) => e.key.trim() !== "");
72+
if (valid.length === 0) return "";
73+
const entries = valid.map(
74+
(e) => ` "${e.key.trim()}": "${e.value.trim()}"`,
75+
);
6176
return `headers:\n${entries.join("\n")}`;
6277
}
6378

@@ -110,6 +125,8 @@ driver: ${driverName}`;
110125
if (value === undefined) return false;
111126
// Filter out empty strings for optional fields
112127
if (typeof value === "string" && value.trim() === "") return false;
128+
// Filter out empty arrays (e.g. key-value inputs with no entries)
129+
if (Array.isArray(value) && value.length === 0) return false;
113130
// For ClickHouse, exclude managed: false as it's the default behavior
114131
// When managed=false, it's the default self-managed mode and doesn't need to be explicit
115132
if (
@@ -124,8 +141,10 @@ driver: ${driverName}`;
124141
const key = property.key as string;
125142
const value = formValues[key] as string;
126143

127-
if (key === "headers" && typeof value === "string") {
128-
return formatHeadersAsYamlMap(value);
144+
if (key === "headers") {
145+
return formatHeadersAsYamlMap(
146+
value as Array<{ key: string; value: string }> | string,
147+
);
129148
}
130149

131150
const isSecretProperty = secretPropertyKeys.includes(key);

web-common/src/features/sources/modal/FormValidation.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ export function createConnectorForm(args: {
4343
// Get schema defaults (radio/tabs enums, explicit defaults)
4444
const schemaDefaults = schema ? getSchemaInitialValues(schema) : {};
4545

46-
// Initialize ALL string fields to empty string so superForm tracks them.
46+
// Initialize ALL fields so superForm tracks them.
4747
// Without this, fields like `path` (no default) won't be in form.data on submit.
4848
const allFields: FormData = {};
4949
if (schema?.properties) {
5050
for (const [key, prop] of Object.entries(schema.properties)) {
51-
if (prop.type === "string") {
51+
if (prop["x-display"] === "key-value") {
52+
allFields[key] = [];
53+
} else if (prop.type === "string") {
5254
allFields[key] = "";
5355
}
5456
}

web-common/src/features/sources/modal/MultiStepConnectorFlow.svelte

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import {
88
findRadioEnumKey,
99
getRadioEnumOptions,
10+
getSchemaButtonLabels,
1011
getSchemaInitialValues,
1112
} from "../../templates/schema-utils";
1213
import { getConnectorSchema } from "./connector-schemas";
@@ -167,18 +168,22 @@
167168
$paramsErrors,
168169
stepState.step,
169170
);
171+
$: schemaButtonLabels = getSchemaButtonLabels(activeSchema, $form);
170172
$: primaryButtonLabel = formManager.getPrimaryButtonLabel({
171173
isConnectorForm: formManager.isConnectorForm,
172174
step: stepState.step,
173175
submitting,
176+
schemaButtonLabels,
174177
selectedAuthMethod: activeAuthMethod ?? selectedAuthMethod,
175178
});
176179
$: primaryLoadingCopy =
177180
stepState.step === "source" || stepState.step === "explorer"
178181
? "Importing data..."
179-
: activeAuthMethod === "public"
180-
? "Continuing..."
181-
: "Testing connection...";
182+
: schemaButtonLabels?.loading
183+
? schemaButtonLabels.loading
184+
: activeAuthMethod === "public"
185+
? "Continuing..."
186+
: "Testing connection...";
182187
$: formId = baseFormId;
183188
$: shouldShowSkipLink =
184189
stepState.step === "connector" && formManager.isMultiStepConnector;

web-common/src/features/templates/SchemaField.svelte

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import Checkbox from "@rilldata/web-common/components/forms/Checkbox.svelte";
55
import Radio from "@rilldata/web-common/components/forms/Radio.svelte";
66
import CredentialsInput from "@rilldata/web-common/components/forms/CredentialsInput.svelte";
7+
import KeyValueInput from "@rilldata/web-common/components/forms/KeyValueInput.svelte";
78
import { normalizeErrors } from "./error-utils";
89
import type { JSONSchemaField } from "./schemas/types";
910
@@ -45,6 +46,15 @@
4546
hint={prop.description ?? prop["x-hint"]}
4647
{optional}
4748
/>
49+
{:else if prop["x-display"] === "key-value"}
50+
<KeyValueInput
51+
{id}
52+
label={prop.title ?? id}
53+
hint={prop.description ?? prop["x-hint"]}
54+
{optional}
55+
bind:value
56+
keyPlaceholder={prop["x-placeholder"]}
57+
/>
4858
{:else if options?.length}
4959
<Radio bind:value {options} {name} />
5060
{:else}

web-common/src/features/templates/schema-utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ export function getSchemaInitialValues(
111111
initial[key] = prop.default;
112112
continue;
113113
}
114+
if (prop["x-display"] === "key-value") {
115+
initial[key] = [];
116+
continue;
117+
}
114118
if (
115119
prop.enum?.length &&
116120
(prop["x-display"] === "radio" || prop["x-display"] === "tabs")
@@ -388,6 +392,7 @@ export function getBackendConnectorName(
388392
/**
389393
* Returns custom button labels from the schema based on current form values.
390394
* Looks up x-button-labels[fieldKey][fieldValue] for each field in values.
395+
* A wildcard key "*" always matches regardless of form values.
391396
*/
392397
export function getSchemaButtonLabels(
393398
schema: MultiStepFormSchema | null,
@@ -396,6 +401,10 @@ export function getSchemaButtonLabels(
396401
const buttonLabelsMap = schema?.["x-button-labels"];
397402
if (!buttonLabelsMap) return null;
398403

404+
// Check for wildcard match first
405+
const wildcard = buttonLabelsMap["*"]?.["*"];
406+
if (wildcard) return wildcard;
407+
399408
for (const [fieldKey, valueLabels] of Object.entries(buttonLabelsMap)) {
400409
const currentValue = values[fieldKey];
401410
if (currentValue === undefined || currentValue === null) continue;

web-common/src/features/templates/schemas/https.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ export const httpsSchema: MultiStepFormSchema = {
55
type: "object",
66
title: "HTTP(S)",
77
"x-category": "fileStore",
8+
"x-button-labels": {
9+
"*": { "*": { idle: "Continue", loading: "Continuing..." } },
10+
},
811
properties: {
912
headers: {
10-
type: "string",
1113
title: "Headers",
1214
description: "HTTP headers to include in the request",
13-
"x-display": "textarea",
14-
"x-placeholder": "Authorization: Bearer <token>",
15+
"x-display": "key-value",
16+
"x-placeholder": "Header name",
17+
"x-hint": "e.g. Authorization: Bearer &lt;token&gt;",
1518
"x-step": "connector",
1619
},
1720
path: {
@@ -33,5 +36,5 @@ export const httpsSchema: MultiStepFormSchema = {
3336
"x-step": "source",
3437
},
3538
},
36-
required: ["headers", "path", "name"],
39+
required: ["path", "name"],
3740
};

web-common/src/features/templates/schemas/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export type JSONSchemaField = {
2020
properties?: Record<string, JSONSchemaField>;
2121
required?: string[];
2222
/** Render style override for the field (e.g. radio buttons, tabs, file picker). */
23-
"x-display"?: "radio" | "select" | "textarea" | "file" | "tabs";
23+
"x-display"?: "radio" | "select" | "textarea" | "file" | "tabs" | "key-value";
2424
/** Which modal step this field belongs to. */
2525
"x-step"?: "connector" | "source" | "explorer";
2626
/** Field holds a secret value that should be stored in .env, not in YAML. */

0 commit comments

Comments
 (0)