From 4182168a2b6c0221a98d9d03c174f0fc93361eae Mon Sep 17 00:00:00 2001
From: Michael B Reiser
Date: Mon, 1 Jun 2026 17:11:10 -0400
Subject: [PATCH] feat(v3): add/remove plugins from a dropdown + "New" button
(v0.20)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses Lisa's feedback: the v3 editor only exposed commands for
plugins already declared in the imported YAML, with no way to add a
new one (e.g. a thermometer for temperature monitoring). Creating a
protocol from scratch was also hidden inside the demo dropdown.
- Settings → Plugins is now editable: a "+ Add plugin…" dropdown lists
supported plugins from the registry (backlight, thermometer, camera),
excluding ones already declared, with an Add button and a per-plugin
✕ remove button (confirm dialog warns if commands still reference it).
Newly added plugins' commands appear in the Inspector "+ add" picker
immediately (listV3PluginNames reads experiment.plugins[]).
- New "+ New" toolbar button loads the blank skeleton (was only
reachable via the demo dropdown); removed the redundant demo entry.
- New onAddPlugin/onRemovePlugin/countPluginUsage handlers follow the
existing mutation pattern (importMode guard, pushUndo, renderAll);
newBtn added to IMPORT_LOCKED_CONTROLS.
- protocol-yaml-v3.js: new docRemovePlugin(); add path reuses
createPluginEntry → _doc.createNode → docInsertPluginNode.
- Tests: new Suite N11 (add/remove/roundtrip) — 592/592 pass.
- Quickstart + footer updated to v0.20.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
experiment_designer_v3.html | 183 +++++++++++++++++++++----
experiment_designer_v3_quickstart.html | 19 ++-
js/protocol-yaml-v3.js | 44 +++++-
tests/test-protocol-roundtrip-v3.js | 64 ++++++++-
4 files changed, 274 insertions(+), 36 deletions(-)
diff --git a/experiment_designer_v3.html b/experiment_designer_v3.html
index cfb16c4..761cbbb 100644
--- a/experiment_designer_v3.html
+++ b/experiment_designer_v3.html
@@ -1194,9 +1194,8 @@ v3 Experiment Designer
-
The interface
Three columns over a flattened-timeline preview. The left column is split into a
@@ -246,15 +246,16 @@
The interface
Experiment Sequence — the ordered run: bare refs to conditions and blocks (groups of trials with repetitions / randomize / intertrial).
Inspector — edits the selected item: a condition's commands, a block's properties, a ref.
Timeline — a read-only preview of the flattened run order.
- Settings ▾ (header) — experiment metadata, the rig path, and the read-only plugins list.
+ Settings ▾ (header) — experiment metadata, the rig path, and the plugins list, where you can add or remove plugins (their commands then appear in the Inspector's "+ add" picker).
Walkthrough: edit and export a protocol
1
-
Load a protocol. Use Import YAML to open your own file, or
- Load demo ▾ to start from a bundled example (e.g. canonical_a).
+
Load or create a protocol. Use Import YAML to open your own file,
+ + New to start from a minimum-valid blank skeleton, or Load demo ▾ to
+ start from a bundled example (e.g. canonical_a).
@@ -262,6 +263,10 @@
Walkthrough: edit and export a protocol
Inspect a condition. Click any row in the Library. Its commands appear in the
Inspector — controller (trialParams), wait, and plugin commands.
Edit fields inline; changes write straight to the YAML model.
+
Need a plugin command that isn't listed (e.g. a thermometer for temperature
+ monitoring)? Open Settings ▾ → Plugins, pick the plugin from the
+ dropdown, and click Add — its commands appear in the "+ add" picker immediately. Set
+ config values (device id, channels, ports) in the exported YAML.
diff --git a/js/protocol-yaml-v3.js b/js/protocol-yaml-v3.js
index 02a3892..b47a7bb 100644
--- a/js/protocol-yaml-v3.js
+++ b/js/protocol-yaml-v3.js
@@ -2050,6 +2050,44 @@ function docInsertPluginNode(experiment, clonedPluginNode) {
experiment.plugins.push(extractPlugin(jsObj));
}
+/**
+ * docRemovePlugin(experiment, pluginName)
+ *
+ * Remove the plugin entry named `pluginName` from the `plugins:` seq and from
+ * the experiment.plugins[] mirror. No-op (returns false) if not found. Does NOT
+ * touch commands that reference the plugin — callers warn the user first. The
+ * `plugins:` seq node is left in place even when it becomes empty (matches the
+ * blank-template `plugins: []` shape).
+ *
+ * @returns {boolean} true if a plugin was removed
+ */
+function docRemovePlugin(experiment, pluginName) {
+ if (!experiment || !experiment._doc) {
+ throw new V3ParseError('docRemovePlugin: experiment has no _doc handle', 'NO_DOC');
+ }
+ const pluginsNode = experiment._doc.getIn(['plugins'], true);
+ let removed = false;
+ if (pluginsNode && Array.isArray(pluginsNode.items)) {
+ for (let i = 0; i < pluginsNode.items.length; i++) {
+ const item = pluginsNode.items[i];
+ const name = item && typeof item.get === 'function' ? item.get('name') : undefined;
+ if (name === pluginName) {
+ pluginsNode.items.splice(i, 1);
+ removed = true;
+ break;
+ }
+ }
+ }
+ if (Array.isArray(experiment.plugins)) {
+ const idx = experiment.plugins.findIndex((p) => p && p.name === pluginName);
+ if (idx !== -1) {
+ experiment.plugins.splice(idx, 1);
+ removed = true;
+ }
+ }
+ return removed;
+}
+
// ════════════════════════════════════════════════════
// Exports
// ════════════════════════════════════════════════════
@@ -2095,7 +2133,8 @@ const ProtocolV3 = {
ensureTopLevelSection,
docInsertConditionNode,
docInsertVariableNode,
- docInsertPluginNode
+ docInsertPluginNode,
+ docRemovePlugin
};
// Browser global
@@ -2150,6 +2189,7 @@ export {
ensureTopLevelSection,
docInsertConditionNode,
docInsertVariableNode,
- docInsertPluginNode
+ docInsertPluginNode,
+ docRemovePlugin
};
export default ProtocolV3;
diff --git a/tests/test-protocol-roundtrip-v3.js b/tests/test-protocol-roundtrip-v3.js
index cca5fab..f93173f 100644
--- a/tests/test-protocol-roundtrip-v3.js
+++ b/tests/test-protocol-roundtrip-v3.js
@@ -62,7 +62,8 @@ const {
ensureTopLevelSection,
docInsertConditionNode,
docInsertVariableNode,
- docInsertPluginNode
+ docInsertPluginNode,
+ docRemovePlugin
} = require('../js/protocol-yaml-v3.js');
const {
@@ -71,6 +72,7 @@ const {
getV3PluginCommands,
listV3PluginNames,
getV3CommandParams,
+ createPluginEntry,
LOG_PLUGIN
} = require('../js/plugin-registry.js');
@@ -3265,6 +3267,66 @@ console.log('\n--- Suite N10: preflight rejection ---');
check('N10: suggestUniqueName bumps suffix', suggestUniqueName('x', new Set(['x', 'x_2'])), 'x_3');
}
+// ─── Suite N11: add/remove plugin from registry (v0.20 UI feature) ───────────
+console.log('\n--- Suite N11: add/remove plugin from registry ---');
+{
+ // Add a registry plugin to a protocol that starts with `plugins: []`
+ // (the blank-template / from-scratch shape). The UI builds the entry via
+ // createPluginEntry → _doc.createNode → docInsertPluginNode.
+ const yaml = mkProtocol({
+ name: 'scratch',
+ plugins: 'plugins: []',
+ conditions: ' - name: c0\n commands: [{type: wait, duration: 1}]'
+ });
+ const exp = parseV3Protocol(yaml);
+ check('N11: starts with zero plugins', exp.plugins.length, 0);
+
+ const entry = createPluginEntry('thermometer');
+ checkTrue(
+ 'N11: createPluginEntry builds thermometer w/ matlab class',
+ !!entry && entry.matlab && entry.matlab.class === 'DAQThermometerPlugin'
+ );
+
+ const node = exp._doc.createNode(entry);
+ docInsertPluginNode(exp, node);
+ check('N11: mirror has the plugin', exp.plugins.length, 1);
+ check('N11: mirror plugin name', exp.plugins[0].name, 'thermometer');
+
+ // Commands now resolve via matlab.class → available in the Add-command picker
+ checkTrue('N11: thermometer in listV3PluginNames', listV3PluginNames(exp).includes('thermometer'));
+ checkTrue(
+ 'N11: thermometer commands resolve',
+ Object.keys(getV3PluginCommands(exp, 'thermometer')).length > 0
+ );
+
+ // Re-parse the regenerated YAML — the new plugin survives a roundtrip
+ const reparsed = parseV3Protocol(generateV3Protocol(exp));
+ check('N11: plugin survives roundtrip', reparsed.plugins.length, 1);
+ check('N11: roundtrip plugin class', reparsed.plugins[0].matlab.class, 'DAQThermometerPlugin');
+
+ // Now remove it — both the doc node and the mirror drop
+ checkTrue('N11: docRemovePlugin returns true', docRemovePlugin(exp, 'thermometer') === true);
+ check('N11: mirror empty after remove', exp.plugins.length, 0);
+ checkTrue(
+ 'N11: plugins: seq empty after remove',
+ exp._doc.getIn(['plugins'], true).items.length === 0
+ );
+ const reparsed2 = parseV3Protocol(generateV3Protocol(exp));
+ check('N11: removal survives roundtrip', reparsed2.plugins.length, 0);
+
+ // Removing a non-existent plugin is a no-op
+ checkTrue('N11: remove missing plugin → false', docRemovePlugin(exp, 'nope') === false);
+
+ // Add into a protocol that has NO plugins: key at all — section is created
+ const noPlug = parseV3Protocol(readFixture('v3_no_variables.yaml'));
+ const before = noPlug.plugins.length;
+ docInsertPluginNode(noPlug, noPlug._doc.createNode(createPluginEntry('camera')));
+ check('N11: camera added to no-plugins-key protocol', noPlug.plugins.length, before + 1);
+ checkTrue('N11: plugins: section now exists', !!noPlug._doc.getIn(['plugins'], true));
+ parseV3Protocol(generateV3Protocol(noPlug)); // must not throw
+ pass('N11: no-plugins-key protocol re-parses after add');
+}
+
// ─── Results ────────────────────────────────────────────────────────────────
console.log('\n=== Results: ' + passedTests + '/' + totalTests + ' passed ===');
if (failedTests.length > 0) {