Refactoring Guide: Widget Export to SaveData Command
Overview
This guide documents how to refactor a data inspector widget from calling a format-specific save function directly to dispatching through the SaveData command. The concrete example is DigitalEventSeriesInspector, but the pattern applies to any widget that exports data.
Before
Widget → CSVEventSaverOptions → ::save(data, options)
The widget directly includes the format-specific header, constructs format-specific options, and calls the save function. This creates a tight coupling between the widget and the file format implementation.
After
Widget → SaveDataParams → SaveData::execute() → LoaderRegistry → CSVLoader → ::save()
The widget constructs generic SaveDataParams with serialized format options (as rfl::Generic), creates a SaveData command, and executes it. The command dispatches through the LoaderRegistry, which routes to the correct format saver. The widget no longer needs to know about the specific save function or its options struct.
Benefits
Decoupled headers — The widget header no longer includes format-specific types (
CSVEventSaverOptions). This reduces compile-time dependencies and makes the widget independent of save format implementation details.Command architecture integration — Save operations can be recorded by the
CommandRecorder, replayed in triage pipelines, and included in golden trace tests.Uniform error handling — The
CommandResulttype provides consistent success/error reporting across all save operations.Extensible — Adding a new export format only requires registering a new saver in the
LoaderRegistry. The widget code doesn’t change.
Step-by-Step Guide
Step 1: Remove Format-Specific Types from the Header
Before (DigitalEventSeriesInspector.hpp):
#include "DataManager/IO/formats/CSV/digitaltimeseries/Digital_Event_Series_CSV.hpp"
// ...
#include <variant>
// ...
using EventSaverOptionsVariant = std::variant<CSVEventSaverOptions>;
class DigitalEventSeriesInspector : public BaseInspector {
// ...
private:
enum SaverType { CSV };
void _initiateSaveProcess(SaverType saver_type, EventSaverOptionsVariant & options_variant);
bool _performActualCSVSave(CSVEventSaverOptions const & options);
private slots:
void _handleSaveEventCSVRequested(CSVEventSaverOptions options);
};After:
// No format-specific includes
// No variant include (unless used elsewhere)
class DigitalEventSeriesInspector : public BaseInspector {
// ...
private:
// No SaverType enum
// No format-specific save methods
// No format-specific slot declarations
};The following items are removed from the header:
#include "DataManager/IO/formats/CSV/digitaltimeseries/Digital_Event_Series_CSV.hpp"#include <variant>using EventSaverOptionsVariant = ...enum SaverType { CSV }_initiateSaveProcess()declaration_performActualCSVSave()declaration_handleSaveEventCSVRequested()slot declaration
Step 2: Add Command Includes to the Implementation
In the .cpp file, add the command headers:
#include "Commands/Core/CommandContext.hpp"
#include "Commands/IO/SaveData.hpp"
#include <rfl/json.hpp>
#include <filesystem>The format-specific include (Digital_Event_Series_CSV.hpp) is no longer needed directly. It arrives transitively through the saver widget header (CSVEventSaver_Widget.hpp), which the .cpp still includes for the Qt signal connection.
Step 3: Replace the Signal/Slot Connection with a Lambda
The saver widget (CSVEventSaver_Widget) emits a signal carrying the format-specific options type (CSVEventSaverOptions). Since this type is no longer in the inspector header, use a lambda in _connectSignals() to handle the conversion.
Before:
connect(ui->csv_event_saver_widget, &CSVEventSaver_Widget::saveEventCSVRequested,
this, &DigitalEventSeriesInspector::_handleSaveEventCSVRequested);After:
connect(ui->csv_event_saver_widget, &CSVEventSaver_Widget::saveEventCSVRequested,
this, [this](CSVEventSaverOptions options) {
// ... command execution (see Step 4)
});The lambda body has access to the CSVEventSaverOptions type because the .cpp includes CSVEventSaver_Widget.hpp which transitively includes the CSV header. The inspector header remains free of format-specific types.
Step 4: Implement the Command Execution in the Lambda
The lambda replaces three old methods (_handleSaveEventCSVRequested, _initiateSaveProcess, _performActualCSVSave) with a single, linear flow:
[this](CSVEventSaverOptions options) {
// 1. Validate output directory
auto output_path = dataManager()->getOutputPath();
if (output_path.empty()) {
QMessageBox::warning(this, "Warning",
"Please set an output directory in the Data Manager settings");
return;
}
// 2. Build the full file path
auto filename = ui->filename_edit->text().toStdString();
auto const filepath = (std::filesystem::path(output_path) / filename).string();
// 3. Set path fields on the options struct
// (the CSVLoader extracts these from format_options)
options.parent_dir = output_path;
options.filename = filename;
// 4. Serialize the format-specific options to rfl::Generic
auto const opts_json = rfl::json::write(options);
auto format_opts = rfl::json::read<rfl::Generic>(opts_json);
// 5. Construct SaveDataParams
commands::SaveDataParams params{
.data_key = _active_key,
.format = "csv",
.path = filepath,
};
if (format_opts) {
params.format_options = format_opts.value();
}
// 6. Build CommandContext and execute
commands::CommandContext ctx;
ctx.data_manager = dataManager();
commands::SaveData cmd(std::move(params));
auto result = cmd.execute(ctx);
// 7. Report result to user
if (result.success) {
QMessageBox::information(this, "Success", "Events saved successfully to CSV");
} else {
QMessageBox::critical(this, "Error",
QString("Failed to save: %1").arg(
QString::fromStdString(result.error_message)));
}
}Key conversion: Format options → rfl::Generic
The SaveData command accepts format-specific options as std::optional<rfl::Generic>. To convert a concrete options struct (like CSVEventSaverOptions):
- Serialize the struct to a JSON string with
rfl::json::write(options) - Parse it back as
rfl::Genericwithrfl::json::read<rfl::Generic>(json_str) - Assign to
SaveDataParams::format_options
This works for any struct that reflect-cpp can serialize (aggregate structs with public fields).
Step 5: Remove Old Save Methods
Delete the following method implementations from the .cpp file:
_handleSaveEventCSVRequested()_initiateSaveProcess()_performActualCSVSave()
These are fully replaced by the lambda in Step 4.
Step 6: Write Tests
Create a test file that verifies the SaveData command works for the data type. The test doesn’t need Qt — it tests the command layer directly:
TEST_CASE("SaveData saves DigitalEventSeries to CSV",
"[commands][SaveData][DigitalEvent]") {
// Setup: create a DataManager with test events
auto dm = std::make_shared<DataManager>();
dm->setData<DigitalEventSeries>("spikes", TimeKey("time"));
auto events = dm->getData<DigitalEventSeries>("spikes");
events->addEvent(TimeFrameIndex(100));
events->addEvent(TimeFrameIndex(200));
// Execute the command
commands::CommandContext ctx;
ctx.data_manager = dm;
commands::SaveData cmd(commands::SaveDataParams{
.data_key = "spikes",
.format = "csv",
.path = "/tmp/test_events.csv",
});
auto result = cmd.execute(ctx);
REQUIRE(result.success);
REQUIRE(std::filesystem::exists("/tmp/test_events.csv"));
}For a round-trip test, load the saved file back and verify the data matches:
TEST_CASE("SaveData round-trips DigitalEventSeries through CSV",
"[commands][SaveData][DigitalEvent]") {
// ... save as above ...
// Load back
CSVEventLoaderOptions load_opts;
load_opts.filepath = filepath;
load_opts.has_header = true;
auto loaded = load(load_opts);
REQUIRE(loaded.size() == 1);
REQUIRE(loaded[0]->size() == original_event_count);
}Data Flow Diagram
┌──────────────────────┐
│ CSVEventSaver_Widget│
│ (UI for CSV options)│
└──────────┬───────────┘
│ signal: saveEventCSVRequested(CSVEventSaverOptions)
▼
┌──────────────────────────────────────────────┐
│ DigitalEventSeriesInspector (lambda) │
│ │
│ 1. Get output path from DataManager │
│ 2. Build filepath = output_path / filename │
│ 3. rfl::json::write(options) → JSON string │
│ 4. rfl::json::read<rfl::Generic>(json) │
│ 5. Construct SaveDataParams │
│ 6. Create SaveData command │
│ 7. Execute with CommandContext │
└──────────────────────┬───────────────────────┘
│ cmd.execute(ctx)
▼
┌──────────────────────────────────────────────┐
│ SaveData::execute() │
│ │
│ 1. Get data type from DataManager │
│ 2. Get raw data pointer from variant │
│ 3. Convert format_options → nlohmann::json │
│ 4. Call LoaderRegistry::trySave() │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ LoaderRegistry::trySave() │
│ │
│ Routes to CSVLoader::save() based on │
│ format="csv" + DM_DataType::DigitalEvent │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ CSVLoader::saveDigitalEventCSV() │
│ │
│ Extracts fields from nlohmann::json config │
│ into CSVEventSaverOptions struct, then │
│ calls ::save(event_data, save_opts) │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ ::save() in Digital_Event_Series_CSV.cpp │
│ │
│ Atomic write of event times to CSV file │
└──────────────────────────────────────────────┘
Files Changed
| File | Change |
|---|---|
src/WhiskerToolbox/DataInspector_Widget/DigitalEventSeries/DigitalEventSeriesInspector.hpp |
Removed format-specific include, variant type alias, SaverType enum, direct-save helper declarations, and CSV slot declaration |
src/WhiskerToolbox/DataInspector_Widget/DigitalEventSeries/DigitalEventSeriesInspector.cpp |
Replaced direct CSV save with SaveData command execution via lambda; removed three old methods |
src/Commands/IO/SaveDataDigitalEvent.test.cpp |
New test file: 5 test cases covering save, round-trip, format options, factory, and error handling |
tests/DataManager/CMakeLists.txt |
Added new test file to build |
Checklist for Other Widgets
When applying this pattern to other inspector widgets (AnalogTimeSeries, LineData, etc.):