Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/plugin-api/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin-api/src/slots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** Update one extra field. Pass an empty string to clear it. */
setExtraField: (key: string, value: string) => void;
};
};

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-api/src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions plugins/PLUGIN_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,14 @@ pub struct ConnectionParams {
/// pool hands out.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_script: Option<String>,
/// 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<String, String>,
// Connection ID for stable pooling (not persisted, set at runtime)
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
Expand Down
57 changes: 57 additions & 0 deletions src-tauri/src/models_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&params).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(&params).expect("serialize params");
assert!(!json.contains("extra"));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/plugins/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}
Expand Down
29 changes: 29 additions & 0 deletions src/components/modals/NewConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>;
}

interface SavedConnection {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2483,6 +2505,13 @@ export const NewConnectionModal = ({
/>
</div>

{/* Plugin-owned extra connection fields (opaque `extra` map) */}
<SlotAnchor
name="connection-modal.extra_fields"
context={extraFieldsSlotContext}
className="flex flex-col gap-3"
/>

{/* User + Password */}
<div className="grid grid-cols-2 gap-3">
<FieldInput
Expand Down
2 changes: 1 addition & 1 deletion src/contexts/PluginSlotProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface PluginSlotProviderProps {
* Keep in sync with `packages/plugin-api/src/version.ts` (API_VERSION).
* Bump when the host API shape changes in a way plugin bundles can observe.
*/
const HOST_API_VERSION = "0.1.0";
const HOST_API_VERSION = "0.1.1";

let globalsExposed = false;
function exposePluginGlobals() {
Expand Down
4 changes: 3 additions & 1 deletion src/types/pluginSlots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export type SlotName =
| "sidebar.footer.actions"
| "settings.plugin.actions"
| "settings.plugin.before_settings"
| "connection-modal.connection_content";
| "connection-modal.connection_content"
| "connection-modal.extra_fields";

/**
* Set of all valid slot names derived from the SlotName union.
Expand All @@ -30,6 +31,7 @@ export const VALID_SLOTS: ReadonlySet<string> = new Set<SlotName>([
"settings.plugin.actions",
"settings.plugin.before_settings",
"connection-modal.connection_content",
"connection-modal.extra_fields",
]);

/**
Expand Down
31 changes: 31 additions & 0 deletions src/utils/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

/**
* 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<string, string> | undefined,
key: string,
value: string,
): Record<string, string> | 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;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/utils/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
}

export interface SavedConnectionWithCredentials {
Expand Down
49 changes: 49 additions & 0 deletions tests/utils/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
generateConnectionName,
connectionSubtitle,
getCardClass,
updateExtraField,
type ConnectionParams,
type DatabaseDriver,
} from '../../src/utils/connections';
Expand Down Expand Up @@ -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',
});
});
});
});