feat(plugins): add interactive prompts and richer connection forms
This commit is contained in:
1 parent
fac270fc8a
commit
e4bda693fb
42 files changed
+3922
-200
No files matched your search
@@ -4,9 +4,19 @@ import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import PluginConnectionFields from "./PluginConnectionFields.vue";
|
||||
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const invokePluginMock = vi.fn();
|
||||
const { pickPluginFieldFileMock, tauriRuntime } = vi.hoisted(() => ({
|
||||
pickPluginFieldFileMock: vi.fn(),
|
||||
tauriRuntime: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/plugins/pluginFieldPicker", () => ({
|
||||
PLUGIN_PICKER_MAX_BYTES: 1_048_576,
|
||||
pickPluginFieldFile: (...args: unknown[]) => pickPluginFieldFileMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => tauriRuntime.value }));
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({
|
||||
listLocalSshKeys: vi.fn(async () => [
|
||||
@@ -56,6 +66,72 @@ async function mountFields(initialValues: Record<string, PluginFormFieldValue> =
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("PluginConnectionFields file picker", () => {
|
||||
const pickerContribution = (picker: PluginFormField["picker"], contentField = true): PluginConnectionProviderContribution => ({
|
||||
type: "connection-provider",
|
||||
id: "ssh.connection",
|
||||
label: "SSH",
|
||||
database_type: "ssh",
|
||||
fields: [{ key: "private_key_path", label: "Private key path", type: "text", picker }, ...(contentField ? [{ key: "private_key", label: "Private key", type: "textarea" as const, binding: "secret" as const }] : [])],
|
||||
});
|
||||
|
||||
it("stores the chosen client path on desktop and drops a stale uploaded copy", async () => {
|
||||
tauriRuntime.value = true;
|
||||
pickPluginFieldFileMock.mockReset().mockResolvedValue({ path: "/Users/dev/.ssh/id_rsa", name: "id_rsa" });
|
||||
const provider = pickerContribution({ kind: "file", accept: [".pem"], content_field: "private_key" });
|
||||
const state = await mountContribution(provider, { private_key: "OLD-UPLOADED-KEY" });
|
||||
|
||||
const browse = document.querySelector<HTMLButtonElement>("#ssh-connection-private_key_path-picker");
|
||||
expect(browse?.textContent).toContain("Choose file");
|
||||
browse?.click();
|
||||
|
||||
await vi.waitFor(() => expect(state.values.private_key_path).toBe("/Users/dev/.ssh/id_rsa"));
|
||||
// The plugin prefers content over a path, so picking a path must clear the
|
||||
// previously uploaded key or the stale one would keep winning.
|
||||
expect(state.values.private_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uploads the file content into the paired field on browser hosts", async () => {
|
||||
tauriRuntime.value = false;
|
||||
pickPluginFieldFileMock.mockReset().mockResolvedValue({ content: "UPLOADED-KEY", name: "id_rsa" });
|
||||
const provider = pickerContribution({ kind: "file", accept: [".pem"], content_field: "private_key" });
|
||||
const state = await mountContribution(provider, { private_key_path: "/Users/dev/.ssh/id_rsa" });
|
||||
|
||||
const upload = document.querySelector<HTMLButtonElement>("#ssh-connection-private_key_path-picker");
|
||||
expect(upload?.textContent).toContain("Upload file");
|
||||
upload?.click();
|
||||
|
||||
await vi.waitFor(() => expect(state.values.private_key).toBe("UPLOADED-KEY"));
|
||||
// A browser path would point at the user's machine, not the host's.
|
||||
expect(state.values.private_key_path).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides a browser-unsupported picker and keeps the desktop one available", async () => {
|
||||
const provider = pickerContribution({ kind: "file" }, false);
|
||||
|
||||
tauriRuntime.value = false;
|
||||
await mountContribution(provider, {});
|
||||
expect(document.querySelector("#ssh-connection-private_key_path-picker")).toBeNull();
|
||||
|
||||
tauriRuntime.value = true;
|
||||
await mountContribution(provider, {});
|
||||
expect(document.querySelector("#ssh-connection-private_key_path-picker")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a picker failure without touching the form", async () => {
|
||||
tauriRuntime.value = false;
|
||||
pickPluginFieldFileMock.mockReset().mockRejectedValue(new Error("File is larger than 1024 KiB"));
|
||||
const provider = pickerContribution({ kind: "file", content_field: "private_key" });
|
||||
const state = await mountContribution(provider, { private_key: "KEEP-ME" });
|
||||
|
||||
document.querySelector<HTMLButtonElement>("#ssh-connection-private_key_path-picker")?.click();
|
||||
|
||||
await vi.waitFor(() => expect(pickPluginFieldFileMock).toHaveBeenCalled());
|
||||
await nextTick();
|
||||
expect(state.values).toEqual({ private_key: "KEEP-ME" });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
@@ -111,6 +187,39 @@ describe("PluginConnectionFields", () => {
|
||||
expect(state.values).toEqual({ password: "secret", host: "db.internal" });
|
||||
});
|
||||
|
||||
it('renders a null value as an empty field instead of the text "null"', async () => {
|
||||
// Hosts before the manifest serialization fix hydrated untouched fields with
|
||||
// `null`, and a null reaching an <input> is coerced to the string "null" by
|
||||
// the DOM — a password field then looked non-empty without any user input.
|
||||
const provider: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "null.connection",
|
||||
label: "Nulls",
|
||||
database_type: "nulls",
|
||||
fields: [
|
||||
{ key: "sudo_password", label: "Sudo password", type: "password", binding: "secret" },
|
||||
{ key: "sudo_command", label: "Sudo command", type: "text", binding: "config" },
|
||||
{ key: "set_env", label: "Env", type: "textarea", binding: "config" },
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(provider, {
|
||||
sudo_password: null,
|
||||
sudo_command: null,
|
||||
set_env: null,
|
||||
} as unknown as Record<string, PluginFormFieldValue>);
|
||||
|
||||
expect(document.querySelector<HTMLInputElement>("#null-connection-sudo_password")?.value).toBe("");
|
||||
expect(document.querySelector<HTMLInputElement>("#null-connection-sudo_command")?.value).toBe("");
|
||||
expect(document.querySelector<HTMLTextAreaElement>("#null-connection-set_env")?.value).toBe("");
|
||||
|
||||
// Editing still emits a real value, so a null never survives the dialog.
|
||||
const command = document.querySelector<HTMLInputElement>("#null-connection-sudo_command")!;
|
||||
command.value = "sudo -n true";
|
||||
command.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await nextTick();
|
||||
expect(state.values.sudo_command).toBe("sudo -n true");
|
||||
});
|
||||
|
||||
it("can hide host-owned common bindings", async () => {
|
||||
contribution.fields[0].binding = "host";
|
||||
await mountFields({}, ["host"]);
|
||||
@@ -222,6 +331,69 @@ describe("PluginConnectionFields", () => {
|
||||
expect(document.querySelector("#kafka-connection-msk_region")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a field only while every clause of an all_of condition holds (sudo_source + read_only)", async () => {
|
||||
// The SSH plugin cannot express "sudo_source = custom AND read_only =
|
||||
// false" with one clause per field; all_of + a boolean literal covers it.
|
||||
const ssh: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
id: "ssh.connection",
|
||||
label: "SSH",
|
||||
database_type: "ssh",
|
||||
fields: [
|
||||
{
|
||||
key: "read_only",
|
||||
label: "Read only",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: "sudo_source",
|
||||
label: "Sudo source",
|
||||
type: "select",
|
||||
default: "none",
|
||||
options: [
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "sudo_command",
|
||||
label: "Sudo command",
|
||||
type: "text",
|
||||
visible_when: {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
},
|
||||
required_when: {
|
||||
all_of: [
|
||||
{ field: "sudo_source", one_of: ["custom"] },
|
||||
{ field: "read_only", one_of: [false] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = await mountContribution(ssh, {});
|
||||
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull();
|
||||
|
||||
state.values = { sudo_source: "custom", read_only: false };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).not.toBeNull();
|
||||
expect(document.querySelector('label[for="ssh-connection-sudo_command"]')?.textContent).toContain("*");
|
||||
|
||||
// Read-only sessions ignore the sudo block entirely.
|
||||
state.values = { sudo_source: "custom", read_only: true };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull();
|
||||
|
||||
state.values = { sudo_source: "none", read_only: false };
|
||||
await nextTick();
|
||||
expect(document.querySelector("#ssh-connection-sudo_command")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers local SSH keys on private_key_path fields and fills the chosen path", async () => {
|
||||
const withKey: PluginConnectionProviderContribution = {
|
||||
type: "connection-provider",
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { FolderOpen } from "@lucide/vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import PasswordInput from "@/components/ui/PasswordInput.vue";
|
||||
import PasswordTextarea from "@/components/ui/PasswordTextarea.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { pluginFieldIsRequired, pluginFieldIsVisible, type PluginFieldResolver } from "@/lib/plugins/pluginFieldConditions";
|
||||
import { pickPluginFieldFile } from "@/lib/plugins/pluginFieldPicker";
|
||||
import { invokePlugin, listLocalSshKeys } from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type { LocalSshKey, PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldOption, PluginFormFieldValue } from "@/types/database";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -24,12 +29,13 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const formValues = computed(() => props.modelValue);
|
||||
/** Value of any sibling field by key (condition evaluation reads current form values). */
|
||||
function readFieldValue(key: string): PluginFormFieldValue {
|
||||
const field = props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
return field ? (formValues.value[key] ?? field.default) : undefined;
|
||||
return field ? (formValues.value[key] ?? field.default ?? undefined) : undefined;
|
||||
}
|
||||
const resolveSiblingField: PluginFieldResolver = (key) => props.contribution.fields.find((candidate) => candidate.key === key);
|
||||
function fieldVisible(field: PluginFormField): boolean {
|
||||
@@ -43,7 +49,18 @@ const visibleFields = computed(() => props.contribution.fields.filter((field) =>
|
||||
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
|
||||
|
||||
function fieldValue(field: PluginFormField): PluginFormFieldValue {
|
||||
return formValues.value[field.key] ?? field.default ?? defaultValueFor(field);
|
||||
// `null` is "unset": hosts used to hydrate untouched fields with a null
|
||||
// default, and a null reaching an input must not look like a stored value.
|
||||
const raw = formValues.value[field.key];
|
||||
const value = raw ?? field.default ?? defaultValueFor(field);
|
||||
return value === null ? undefined : value;
|
||||
}
|
||||
|
||||
/** Text/number inputs render any primitive as text; `null` stays empty. */
|
||||
function fieldInputValue(field: PluginFormField): string | number {
|
||||
const value = fieldValue(field);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function updateField(field: PluginFormField, value: PluginFormFieldValue) {
|
||||
@@ -174,7 +191,74 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
const selected = localSshKeys.value.find((key) => key.path === select.value);
|
||||
select.value = "";
|
||||
if (!selected) return;
|
||||
updateField(field, selected.path);
|
||||
pickPluginFieldPath(field, selected.path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest-declared local file action (`picker`, Host API 1.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fields whose picker acts on the working copy; the plugin reads the file. */
|
||||
function pickerField(field: PluginFormField) {
|
||||
return field.picker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser hosts cannot hand a path to the plugin, so the picker uploads the
|
||||
* file content into the declared `content_field` instead. A picker without a
|
||||
* `content_field` is desktop-only and stays hidden in the browser.
|
||||
*/
|
||||
function pickerVisible(field: PluginFormField): boolean {
|
||||
const picker = pickerField(field);
|
||||
if (!picker) return false;
|
||||
if (isTauriRuntime()) return true;
|
||||
return picker.kind === "file" && Boolean(picker.content_field);
|
||||
}
|
||||
|
||||
const pickerBusyField = ref<string | null>(null);
|
||||
|
||||
function pickerLabelKey(field: PluginFormField): string {
|
||||
const picker = pickerField(field);
|
||||
if (picker?.kind === "directory") return isTauriRuntime() ? "connection.pluginFieldSelectDirectory" : "connection.pluginFieldSelectFile";
|
||||
return isTauriRuntime() ? "connection.pluginFieldSelectFile" : "connection.pluginFieldUploadFile";
|
||||
}
|
||||
|
||||
/** Path selection (desktop) and the SSH key dropdown share this entry point. */
|
||||
function pickPluginFieldPath(field: PluginFormField, path: string) {
|
||||
const picker = pickerField(field);
|
||||
const next = { ...formValues.value, [field.key]: path };
|
||||
// The plugin treats content as more specific than a path, so selecting a path
|
||||
// must clear the uploaded copy — otherwise a stale upload keeps winning.
|
||||
if (picker?.content_field) delete next[picker.content_field];
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
/** Upload content into the paired field and clear the (meaningless) path. */
|
||||
function pickPluginFieldContent(field: PluginFormField, contentKey: string, content: string) {
|
||||
const next = { ...formValues.value, [contentKey]: content };
|
||||
delete next[field.key];
|
||||
emit("update:modelValue", next);
|
||||
}
|
||||
|
||||
async function runPluginFieldPicker(field: PluginFormField) {
|
||||
const picker = pickerField(field);
|
||||
if (!picker || pickerBusyField.value) return;
|
||||
pickerBusyField.value = field.key;
|
||||
try {
|
||||
const picked = await pickPluginFieldFile(picker);
|
||||
if (!picked) return;
|
||||
if (picked.path !== undefined) {
|
||||
pickPluginFieldPath(field, picked.path);
|
||||
return;
|
||||
}
|
||||
if (picked.content !== undefined && picker.content_field) {
|
||||
pickPluginFieldContent(field, picker.content_field, picked.content);
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
pickerBusyField.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -207,7 +291,7 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-else-if="isLocalSshKeyField(field)" class="flex items-center gap-1.5">
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<Input :id="fieldId(field)" type="text" :model-value="fieldInputValue(field)" :placeholder="field.placeholder" class="min-w-0 flex-1" @update:model-value="updateTextField(field, $event)" />
|
||||
<select
|
||||
class="h-9 w-40 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="localSshKeys.length === 0"
|
||||
@@ -219,7 +303,7 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
<option v-for="key in localSshKeys" :key="key.path" :value="key.path" :title="key.path">{{ localSshKeyLabel(key) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
<Input v-else :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldInputValue(field)" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
|
||||
</template>
|
||||
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
<PasswordTextarea v-else-if="field.type === 'textarea' && field.binding === 'secret'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
|
||||
@@ -244,6 +328,12 @@ function applyLocalSshKey(field: PluginFormField, event: Event) {
|
||||
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
|
||||
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
|
||||
</div>
|
||||
<div v-if="pickerVisible(field)" class="flex items-center gap-2">
|
||||
<Button :id="`${fieldId(field)}-picker`" variant="outline" size="sm" class="h-8 gap-1.5 text-xs" :disabled="pickerBusyField === field.key" @click="runPluginFieldPicker(field)">
|
||||
<FolderOpen class="size-3.5" aria-hidden="true" />
|
||||
{{ t(pickerLabelKey(field)) }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="field.description" class="text-[11px] leading-5 text-muted-foreground">{{ field.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user