diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 852e59172..128ecffa1 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tabularis/plugin-api", - "version": "0.1.0", + "version": "0.1.1", "description": "Public API surface for Tabularis plugin UI extensions.", "license": "Apache-2.0", "homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/plugin-api", diff --git a/packages/plugin-api/src/slots.ts b/packages/plugin-api/src/slots.ts index baf11117e..6c0d7a055 100644 --- a/packages/plugin-api/src/slots.ts +++ b/packages/plugin-api/src/slots.ts @@ -74,6 +74,13 @@ export type SlotContextMap = { "connection-modal.connection_content": { driver: string; }; + "connection-modal.extra_fields": { + driver: string; + /** Current values of the plugin-owned extra connection fields. */ + extra: Record; + /** Update one extra field. Pass an empty string to clear it. */ + setExtraField: (key: string, value: string) => void; + }; }; /** diff --git a/packages/plugin-api/src/version.ts b/packages/plugin-api/src/version.ts index 8d7746cd8..e79983b3b 100644 --- a/packages/plugin-api/src/version.ts +++ b/packages/plugin-api/src/version.ts @@ -2,7 +2,7 @@ * API version of this package. Must match the version field of package.json. * Bump when the host API shape changes in a way that plugin bundles can observe. */ -export const API_VERSION = "0.1.0"; +export const API_VERSION = "0.1.1"; /** * Minimum Tabularis host version that exposes an API compatible with this package. diff --git a/plugins/PLUGIN_GUIDE.md b/plugins/PLUGIN_GUIDE.md index 7b06802c9..8969bd951 100644 --- a/plugins/PLUGIN_GUIDE.md +++ b/plugins/PLUGIN_GUIDE.md @@ -339,6 +339,7 @@ Add an optional `ui_extensions` array to your `manifest.json`: | `settings.plugin.actions` | Per-plugin actions in Settings modal | `targetPluginId` | Diagnostics, re-auth buttons | | `settings.plugin.before_settings` | Content above plugin settings form | `targetPluginId` | OAuth panels, status banners | | `connection-modal.connection_content` | Inside the connection form | `driver` | Custom connection fields | +| `connection-modal.extra_fields` | Below host/port in the connection form | `driver`, `extra`, `setExtraField` | Plugin-specific connection fields (e.g. AWS region) | ### SlotContext diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index ff2247727..7ceaa7235 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -262,6 +262,14 @@ pub struct ConnectionParams { /// pool hands out. #[serde(default, skip_serializing_if = "Option::is_none")] pub startup_script: Option, + /// Opaque, plugin-specific connection fields. The host does not interpret + /// these — they are persisted verbatim and forwarded to the driver plugin + /// as part of `params`, so plugins can carry custom connection settings + /// (e.g. an AWS region for DynamoDB) without core schema changes. + /// Rendered by plugins through the `connection-modal.extra_fields` slot. + /// Absent from the JSON when empty. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, // Connection ID for stable pooling (not persisted, set at runtime) #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, diff --git a/src-tauri/src/models_tests.rs b/src-tauri/src/models_tests.rs index 4dbdac134..e6d4d1967 100644 --- a/src-tauri/src/models_tests.rs +++ b/src-tauri/src/models_tests.rs @@ -139,4 +139,61 @@ mod tests { ); assert_eq!(params.connection_uri_in_keychain, Some(true)); } + + /// `extra` is optional on the wire: connections persisted before the + /// field existed must deserialize with an empty map, not an error. + #[test] + fn connection_params_default_extra_when_absent() { + let stored = r#"{ + "driver": "mysql", + "host": "localhost", + "database": "app" + }"#; + + let params: ConnectionParams = + serde_json::from_str(stored).expect("legacy params without extra"); + assert!(params.extra.is_empty()); + } + + /// `extra` entries survive a persist round-trip untouched — the host must + /// not interpret or rewrite plugin-specific values. + #[test] + fn connection_params_round_trip_extra_fields() { + let params = ConnectionParams { + driver: "dynamodb".to_string(), + database: DatabaseSelection::Single(String::new()), + extra: [ + ("region".to_string(), "eu-west-1".to_string()), + ("endpoint".to_string(), "http://localhost:8000".to_string()), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let json = serde_json::to_string(¶ms).expect("serialize params"); + assert!(json.contains("\"extra\"")); + + let restored: ConnectionParams = + serde_json::from_str(&json).expect("deserialize params"); + assert_eq!(restored.extra.get("region").map(String::as_str), Some("eu-west-1")); + assert_eq!( + restored.extra.get("endpoint").map(String::as_str), + Some("http://localhost:8000") + ); + } + + /// An empty `extra` map is omitted from the persisted JSON so legacy + /// connections.json files stay byte-identical. + #[test] + fn connection_params_omit_empty_extra() { + let params = ConnectionParams { + driver: "mysql".to_string(), + database: DatabaseSelection::Single("app".to_string()), + ..Default::default() + }; + + let json = serde_json::to_string(¶ms).expect("serialize params"); + assert!(!json.contains("extra")); + } } diff --git a/src-tauri/src/plugins/driver.rs b/src-tauri/src/plugins/driver.rs index 0e01969d3..542a55e62 100644 --- a/src-tauri/src/plugins/driver.rs +++ b/src-tauri/src/plugins/driver.rs @@ -1355,6 +1355,7 @@ mod tests { k8s_kubeconfig_path: None, startup_script: None, use_iam_auth: None, + extra: HashMap::new(), connection_id: Some("conn-1".to_string()), } } diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index ecac6d3a0..71fac7851 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -53,6 +53,7 @@ import { useK8sPathOverrides } from "../../hooks/useK8sPathOverrides"; import { useLatestAsync } from "../../hooks/useLatestAsync"; import { K8sAdvancedSettings } from "../ui/K8sAdvancedSettings"; import { isMultiDatabaseCapable } from "../../utils/database"; +import { updateExtraField } from "../../utils/connections"; import { toErrorMessage } from "../../utils/errors"; import { classifyConnectionError, @@ -141,6 +142,9 @@ interface ConnectionParams { k8s_kubeconfig_path?: string; // SQL run on every new connection (e.g. SET / set_config) startup_script?: string; + // Opaque plugin-specific connection fields, forwarded verbatim to the + // driver/plugin and persisted as-is in connections.json. + extra?: Record; } interface SavedConnection { @@ -616,6 +620,24 @@ export const NewConnectionModal = ({ dbFieldSlotContext, ).length > 0; + // ── plugin slot: connection-modal.extra_fields ── + // Plugins render their own connection fields (e.g. an AWS region select) + // and write them into the opaque `extra` map via `setExtraField`. + const setExtraField = useCallback((key: string, value: string) => { + setFormData((prev) => ({ + ...prev, + extra: updateExtraField(prev.extra, key, value), + })); + }, []); + const extraFieldsSlotContext = useMemo( + () => ({ + driver, + extra: formData.extra ?? {}, + setExtraField, + }), + [driver, formData.extra, setExtraField], + ); + // ── helpers ── const loadSshConnectionsList = async () => { const result = await loadSshConnections(); @@ -2483,6 +2505,13 @@ export const NewConnectionModal = ({ /> + {/* Plugin-owned extra connection fields (opaque `extra` map) */} + + {/* User + Password */}
= new Set([ "settings.plugin.actions", "settings.plugin.before_settings", "connection-modal.connection_content", + "connection-modal.extra_fields", ]); /** diff --git a/src/utils/connections.ts b/src/utils/connections.ts index 259e783a4..053e48699 100644 --- a/src/utils/connections.ts +++ b/src/utils/connections.ts @@ -46,6 +46,37 @@ export interface ConnectionParams { k8s_port?: number; /** SQL run on every new connection to this data source (e.g. SET / set_config). */ startup_script?: string; + /** Opaque plugin-specific connection fields (e.g. `region` for a DynamoDB + * plugin). Persisted as-is and forwarded verbatim to the driver/plugin. */ + extra?: Record; +} + +/** + * Update one entry of the opaque `extra` connection fields map. + * + * Pure function — returns a new map, never mutates its input. + * - Blank keys are ignored (the input is returned unchanged). + * - An empty value removes the key, and the map collapsing to empty yields + * `undefined` so nothing extra is persisted or sent to the backend. + * + * @param extra - Current extra fields map (may be undefined) + * @param key - Field name owned by the plugin + * @param value - New value; empty string clears the field + */ +export function updateExtraField( + extra: Record | undefined, + key: string, + value: string, +): Record | undefined { + const trimmedKey = key.trim(); + if (!trimmedKey) return extra; + const next = { ...(extra ?? {}) }; + if (value === "") { + delete next[trimmedKey]; + } else { + next[trimmedKey] = value; + } + return Object.keys(next).length > 0 ? next : undefined; } /** diff --git a/src/utils/credentials.ts b/src/utils/credentials.ts index cd1079ee0..1ad8ace0e 100644 --- a/src/utils/credentials.ts +++ b/src/utils/credentials.ts @@ -22,6 +22,9 @@ interface ConnectionParams { ssh_key_passphrase?: string; ssh_allow_passphrase_prompt?: boolean; save_in_keychain?: boolean; + /** Opaque plugin-specific connection fields, persisted as-is and forwarded + * verbatim to the driver/plugin. */ + extra?: Record; } export interface SavedConnectionWithCredentials { diff --git a/tests/utils/connections.test.ts b/tests/utils/connections.test.ts index 6f750501d..d40d44061 100644 --- a/tests/utils/connections.test.ts +++ b/tests/utils/connections.test.ts @@ -7,6 +7,7 @@ import { generateConnectionName, connectionSubtitle, getCardClass, + updateExtraField, type ConnectionParams, type DatabaseDriver, } from '../../src/utils/connections'; @@ -515,4 +516,52 @@ describe('connections', () => { expect(generateConnectionName(params, makeRemoteCaps())).toBe('analytics@localhost'); }); }); + + describe('updateExtraField', () => { + it('should set a field on an undefined map', () => { + expect(updateExtraField(undefined, 'region', 'us-east-1')).toEqual({ + region: 'us-east-1', + }); + }); + + it('should set a field alongside existing entries', () => { + const result = updateExtraField({ profile: 'dev' }, 'region', 'eu-west-1'); + expect(result).toEqual({ profile: 'dev', region: 'eu-west-1' }); + }); + + it('should not mutate the input map', () => { + const input = { region: 'us-east-1' }; + updateExtraField(input, 'region', 'eu-west-1'); + expect(input).toEqual({ region: 'us-east-1' }); + }); + + it('should remove the key when the value is empty', () => { + const result = updateExtraField( + { region: 'us-east-1', profile: 'dev' }, + 'region', + '', + ); + expect(result).toEqual({ profile: 'dev' }); + }); + + it('should return undefined when the last entry is removed', () => { + expect(updateExtraField({ region: 'us-east-1' }, 'region', '')).toBeUndefined(); + }); + + it('should return undefined when removing from an undefined map', () => { + expect(updateExtraField(undefined, 'region', '')).toBeUndefined(); + }); + + it('should ignore blank keys', () => { + const input = { region: 'us-east-1' }; + expect(updateExtraField(input, ' ', 'value')).toBe(input); + expect(updateExtraField(undefined, '', 'value')).toBeUndefined(); + }); + + it('should trim the key', () => { + expect(updateExtraField(undefined, ' region ', 'us-east-1')).toEqual({ + region: 'us-east-1', + }); + }); + }); });