Qt Entity Creator
When to use this skill
When you need to add a new entity to the Qt UI layer in ORE Studio. This skill guides you through creating all the necessary Qt components to display, edit, and manage a domain entity, following established patterns in the codebase.
Prerequisites:
- The domain type must already exist (see domain-type-creator skill)
- The messaging protocol for CRUD operations must exist (see binary-protocol-developer skill)
- The server-side handlers must be implemented
- If real-time updates are needed: The eventing infrastructure must exist (see "Eventing Infrastructure" section in binary-protocol-developer skill)
How to use this skill
Recommended approach: Use code generation first. The ores.codegen project can
generate all Qt UI components from JSON models, ensuring consistency with
established patterns.
Priority order
- Use code generation: Create a JSON model and generate Qt components using
--profile qt. See ORE Studio Codegen for details. - Update templates: If the entity doesn't fit existing templates, modify the
Qt Mustache templates in
library/templates/to support the new pattern. - Manual creation: Only create Qt components manually as a last resort when code generation cannot support the required UI pattern.
Code generation workflow
- Ensure a JSON model exists in
projects/ores.codegen/models/{component}/ Generate all Qt UI components:
cd projects/ores.codegen ./run_generator.sh models/{component}/{entity}_domain_entity.json output/ --profile qt- Review the generated output in
output/ - Copy files to
projects/ores.qt/:- Headers to
include/ores.qt/ - Sources to
src/ - UI files to
ui/
- Headers to
- Integrate controller into MainWindow (Step 8 below)
- Build and test
- Raise PRs at designated checkpoints
Manual workflow (last resort)
- Gather entity requirements (name, fields, features needed).
- Follow the detailed instructions to create components in order.
- Each phase ends with a PR checkpoint - raise PR, wait for review, merge.
- Create a fresh branch from main for the next phase (see feature-branch-manager).
- Build and test after each step within a phase.
PR Strategy
This skill is structured into four phases, each resulting in a separate PR. This keeps PRs reviewable and allows incremental integration.
| Phase | Steps | PR Title Template |
|---|---|---|
| 1 | Steps 1-2 | [qt] Add <Entity> model and list window |
| 2 | Steps 3-4 | [qt] Add <Entity> detail dialog |
| 3 | Steps 5-6 | [qt] Add <Entity> history dialog |
| 4 | Steps 7-9 | [qt] Integrate <Entity> controller and update diagrams |
After each PR is merged, use the feature-branch-manager skill to transition to the next phase. This ensures clean git history by creating fresh branches from main rather than rebasing.
Detailed instructions
The following sections describe the step-by-step process for creating a complete Qt entity.
Gather Requirements
Before starting, gather the following information:
- Entity name: The name of the entity (e.g.,
currency,country,account). - Component location: Which domain the entity belongs to (e.g.,
ores.refdata,ores.iam). - Display fields: Which fields to show in the list view table.
- Editable fields: Which fields can be edited in the detail dialog.
- Primary key: The unique identifier field (e.g.,
iso_code,id,alpha2_code). - Entity icon: Select an appropriate icon using the icon-guidelines skill.
- Features needed:
[ ]List view with pagination (for large datasets)[ ]Detail dialog (create/edit/view)[ ]History dialog (version tracking)[ ]Server notifications (real-time updates)[ ]Image/flag support[ ]Change reason tracking
Phase 1: Model and List Window
This phase creates the data model and main list view. After completing Steps 1-2, raise a PR.
Suggested PR title: [qt] Add <Entity> model and list window
Step 1: Create Client Model
Create the model that fetches and stores entity data from the server.
File locations
- Header:
projects/ores.qt/include/ores.qt/Client<Entity>Model.hpp - Implementation:
projects/ores.qt/src/Client<Entity>Model.cpp
Header structure
#ifndef ORES_QT_CLIENT_<ENTITY>_MODEL_HPP #define ORES_QT_CLIENT_<ENTITY>_MODEL_HPP #include <vector> #include <QFutureWatcher> #include <QAbstractTableModel> #include "ores.qt/ClientManager.hpp" #include "ores.logging/make_logger.hpp" #include "<component>/domain/<entity>.hpp" namespace ores::qt { class Client<Entity>Model final : public QAbstractTableModel { Q_OBJECT private: inline static std::string_view logger_name = "ores.qt.client_<entity>_model"; [[nodiscard]] static auto& lg() { using namespace ores::logging; static auto instance = make_logger(logger_name); return instance; } public: enum Column { // Define columns matching display fields // Example: IsoCode, Name, Symbol, ..., ColumnCount ColumnCount }; explicit Client<Entity>Model(ClientManager* clientManager, QObject* parent = nullptr); ~Client<Entity>Model() override = default; // QAbstractTableModel interface int rowCount(const QModelIndex& parent = QModelIndex()) const override; int columnCount(const QModelIndex& parent = QModelIndex()) const override; QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; // Data access const <component>::domain::<entity>* get<Entity>(int row) const; void refresh(); // Pagination std::uint32_t page_size() const { return page_size_; } void set_page_size(std::uint32_t size); std::uint32_t total_available_count() const { return total_available_count_; } void load_page(std::uint32_t offset, std::uint32_t limit); signals: void dataLoaded(); void loadError(const QString& error_message, const QString& details = {}); private slots: void onDataLoaded(); private: struct FetchResult { bool success; std::vector<<component>::domain::<entity>> entries; std::uint32_t total_available_count; QString error_message; QString error_details; }; void fetch_data(std::uint32_t offset, std::uint32_t limit); ClientManager* clientManager_; std::vector<<component>::domain::<entity>> entries_; QFutureWatcher<FetchResult>* watcher_; std::uint32_t page_size_{100}; std::uint32_t total_available_count_{0}; bool is_fetching_{false}; }; } #endif
Implementation patterns
The implementation should:
- Use
QtConcurrent::runfor async data fetching. - Use
QPointer<Client<Entity>Model>in lambdas for safety. - Use
exception_helper::wrap_async_fetchto catch exceptions inside the lambda (before Qt can wrap them inQUnhandledException). - Use
beginResetModel()/endResetModel()when replacing data. - Log operations at appropriate levels (debug for start, info for success, error for failures).
- Async fetch pattern with exception handling
Use
process_authenticated_requestfor typed request/response handling. The protocol request must includeoffsetandlimitfields, and the response must includetotal_available_count.#include "ores.qt/ExceptionHelper.hpp" #include "ores.comms/net/client_session.hpp" void Client<Entity>Model::fetch_data(std::uint32_t offset, std::uint32_t limit) { is_fetching_ = true; QPointer<Client<Entity>Model> self = this; QFuture<FetchResult> future = QtConcurrent::run([self, offset, limit]() -> FetchResult { return exception_helper::wrap_async_fetch<FetchResult>([&]() -> FetchResult { if (!self || !self->clientManager_) { return {.success = false, .entries = {}, .total_available_count = 0, .error_message = "Model was destroyed", .error_details = {}}; } // Build typed request with pagination params <component>::messaging::get_<entity>s_request request; request.offset = offset; request.limit = limit; auto result = self->clientManager_-> process_authenticated_request(std::move(request)); if (!result) { return {.success = false, .entries = {}, .total_available_count = 0, .error_message = QString::fromStdString( "Failed to fetch: " + comms::net::to_string(result.error())), .error_details = {}}; } return {.success = true, .entries = std::move(result-><entity>s), .total_available_count = result->total_available_count, .error_message = {}, .error_details = {}}; }, "<entity>s"); // Entity name for error messages }); watcher_->setFuture(future); } void Client<Entity>Model::onDataLoaded() { is_fetching_ = false; const auto result = watcher_->result(); if (!result.success) { BOOST_LOG_SEV(lg(), error) << "Failed to fetch <entity>s: " << result.error_message.toStdString(); emit loadError(result.error_message, result.error_details); return; } beginResetModel(); entries_ = std::move(result.entries); total_available_count_ = result.total_available_count; endResetModel(); emit dataLoaded(); }
The
wrap_async_fetchtemplate catches anystd::exceptionthrown inside the lambda and populates theerror_messageanderror_detailsfields with fullboost::diagnostic_information. This preserves exception details that would otherwise be lost when Qt wraps exceptions inQUnhandledException. - Optional: Recency highlighting for modified records
If you want to highlight recently-modified records in the table view with a pulsing color effect, use the
RecencyTrackerandRecencyPulseManagerutility classes:- Add to header:
#include "ores.qt/RecencyPulseManager.hpp" #include "ores.qt/RecencyTracker.hpp" private slots: void onPulseStateChanged(bool isOn); void onPulsingComplete(); private: QVariant foregroundColor(const std::string& identifier) const; // Recency highlighting - define key extractor type and tracker using KeyExtractor = std::string(*)(const <component>::domain::<entity>&); RecencyTracker<<component>::domain::<entity>, KeyExtractor> recencyTracker_; RecencyPulseManager* pulseManager_;
- Define a key extractor function and initialize in constructor:
namespace { std::string entity_key_extractor(const <component>::domain::<entity>& e) { return e.<primary_key_field>; // e.g., e.name, e.iso_code } } Client<Entity>Model::Client<Entity>Model(...) : QAbstractTableModel(parent), // ...other members... recencyTracker_(entity_key_extractor), pulseManager_(new RecencyPulseManager(this)) { connect(pulseManager_, &RecencyPulseManager::pulse_state_changed, this, &Client<Entity>Model::onPulseStateChanged); connect(pulseManager_, &RecencyPulseManager::pulsing_complete, this, &Client<Entity>Model::onPulsingComplete); }
- In
data()method, handleQt::ForegroundRole:
if (role == Qt::ForegroundRole) { return foregroundColor(entry.<primary_key_field>); }
- Implement the helper methods:
QVariant Client<Entity>Model::foregroundColor(const std::string& id) const { if (recencyTracker_.is_recent(id) && pulseManager_->is_pulse_on()) { return color_constants::stale_indicator; } return {}; } void Client<Entity>Model::onPulseStateChanged(bool /*isOn*/) { if (!entries_.empty()) { emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1), {Qt::ForegroundRole}); } } void Client<Entity>Model::onPulsingComplete() { recencyTracker_.clear(); }
- After loading data, update recency tracking and start pulsing if needed:
recencyTracker_.clear(); pulseManager_->stop_pulsing(); // ... load data into entries_ ... const bool has_recent = recencyTracker_.update(entries_); if (has_recent && !pulseManager_->is_pulsing()) { pulseManager_->start_pulsing(); }
The
RecencyTrackerhandles all timestamp comparison logic automatically, comparing each entity'srecorded_atfield against the last reload time
Commit message
[qt] Add Client<Entity>Model for <entity> data fetching Create Qt model for fetching and displaying <entity> data from the server with async loading and pagination support.
Step 2: Create MDI Window (List View)
Create the main list window showing entities in a table.
Icon selection
Use the icon-guidelines skill to select an appropriate icon for the entity. Common patterns:
- Data entities: Use semantic icons (globe for countries, currency symbols for currencies)
- System entities: Use system-related icons (lock for roles, flag for feature flags)
File locations
- Header:
projects/ores.qt/include/ores.qt/<Entity>MdiWindow.hpp - Implementation:
projects/ores.qt/src/<Entity>MdiWindow.cpp
Header structure
The MdiWindow inherits from EntityListMdiWindow which provides:
markAsStale()/clearStaleIndicator()for stale indicator support- Pure virtual
reload()method initializeStaleIndicator(QAction*, QString)helper for toolbar setupinitializeTableSettings()for column resize, visibility, settings persistence, and window sizenormalRefreshTooltip()/staleRefreshTooltip()virtual methods for customizationsizeHint()override — returns saved window size or default frominitializeTableSettings()saveSettings()— automatically saves header state and window size on close
#ifndef ORES_QT_<ENTITY>_MDI_WINDOW_HPP #define ORES_QT_<ENTITY>_MDI_WINDOW_HPP #include <QTableView> #include <QVBoxLayout> #include <QToolBar> #include <QSortFilterProxyModel> #include <memory> #include "ores.qt/EntityListMdiWindow.hpp" #include "ores.qt/ClientManager.hpp" #include "ores.qt/Client<Entity>Model.hpp" #include "ores.qt/PaginationWidget.hpp" // If pagination needed #include "ores.logging/make_logger.hpp" #include "<component>/domain/<entity>.hpp" namespace ores::qt { class <Entity>MdiWindow final : public EntityListMdiWindow { Q_OBJECT private: inline static std::string_view logger_name = "ores.qt.<entity>_mdi_window"; [[nodiscard]] static auto& lg() { using namespace ores::logging; static auto instance = make_logger(logger_name); return instance; } public: explicit <Entity>MdiWindow(ClientManager* clientManager, const QString& username, QWidget* parent = nullptr); ~<Entity>MdiWindow() override = default; Client<Entity>Model* model() const { return model_.get(); } public slots: void reload() override; // Pure virtual from EntityListMdiWindow signals: void statusChanged(const QString& message); void errorOccurred(const QString& error_message); void selectionChanged(int selection_count); void addNewRequested(); void show<Entity>Details(const <component>::domain::<entity>& entity); void <entity>Deleted(const QString& id); void show<Entity>History(const QString& id); protected: QString normalRefreshTooltip() const override { return tr("Refresh <entity>s"); } private slots: void onDataLoaded(); void onLoadError(const QString& error_message, const QString& details = {}); void onSelectionChanged(); void onRowDoubleClicked(const QModelIndex& index); void onConnectionStateChanged(); void addNew(); void editSelected(); void deleteSelected(); void showHistory(); private: void setupUi(); void setupToolbar(); void updateActionStates(); // Layout QVBoxLayout* verticalLayout_; QTableView* tableView_; QToolBar* toolBar_; PaginationWidget* paginationWidget_; // If pagination needed // Standard actions QAction* addAction_; QAction* editAction_; QAction* deleteAction_; QAction* refreshAction_; QAction* historyAction_; // Model std::unique_ptr<Client<Entity>Model> model_; QSortFilterProxyModel* proxyModel_; ClientManager* clientManager_; QString username_; }; } #endif
Implementation patterns
- Constructor setup sequence
- Initialize member variables.
- Create toolbar with
setupToolbar(). - Setup reload action with stale indicator.
- Add standard actions (Add, Edit, Delete, History).
- Create table view with standard configuration.
- Setup proxy model for sorting/filtering.
- Connect model signals.
- Connect pagination signals (if applicable).
- Connect connection state signals.
- Update action states.
- Load initial data if connected.
- Standard table configuration and settings
After creating the table view and model, call
initializeTableSettings()from the base class. This single call handles:- Setting
ResizeToContentson all columns - Column visibility context menu (right-click header to show/hide columns)
- Saving/restoring header state (column order, visibility, width) via
QSettings - Saving/restoring window size via
QSettings - Settings version management (bump to discard stale saved state)
- Default hidden columns (applied when no saved state or version mismatch)
sizeHint()returns saved window size or the default size you specify
tableView_->setAlternatingRowColors(true); tableView_->setSelectionBehavior(QAbstractItemView::SelectRows); tableView_->setSelectionMode(QAbstractItemView::ExtendedSelection); tableView_->setWordWrap(false); tableView_->setSortingEnabled(true); tableView_->verticalHeader()->setVisible(false); // This replaces ALL manual column setup, save/restore, and column visibility code. // Parameters: tableView, sourceModel, settingsGroup, defaultHiddenColumns, defaultSize, settingsVersion initializeTableSettings(tableView_, model_.get(), "<Entity>ListWindow", {Client<Entity>Model::Description}, // Columns hidden by default (or {} for none) {900, 400}, // Default window size 1); // Bump when column layout changes
Important: Do NOT use
setColumnWidth(),setStretchLastSection(true), orsetSectionResizeMode(QHeaderView::Fixed). All list windows must useResizeToContentsconsistently (set byinitializeTableSettings()) so columns auto-size to actual data and behave identically across all entity windows.Do NOT implement the following in subclasses — they are handled by the base class:
sizeHint()— returns saved window size or default frominitializeTableSettings()saveSettings()— saves header state and window size on closesetupColumnVisibility()/showHeaderContextMenu()— column visibility context menu- Any manual
QSettingssave/restore code
- Setting
- Settings versioning for column layout
When column layout changes (columns added, removed, renamed, or reordered), bump the
settingsVersionparameter ininitializeTableSettings()to invalidate cached header state. This forces users to get the new defaults on next launch. - Non-Latin text detection for transliterated names
When an entity has both a
full_name(possibly in non-Latin script) and atransliterated_name, merge them into the Name column display rather than showing a separate Transliterated Name column. UseTextUtilsfromores.qt/TextUtils.hpp:#include "ores.qt/TextUtils.hpp" // ... case FullName: return TextUtils::display_name_with_transliteration( entity.full_name, entity.transliterated_name);
This detects non-Latin characters (Korean, Chinese, Japanese, Arabic, Cyrillic, etc.) and appends the transliteration in parentheses, truncated to 30 characters with an ellipsis. The Transliterated Name column should be hidden by default (enforced in
restoreSettings()regardless of saved state). - Stale indicator pattern (using EntityListMdiWindow base class)
The base class
EntityListMdiWindowprovides all stale indicator functionality. In yoursetupToolbar(), create the refresh action and callinitializeStaleIndicator():void <Entity>MdiWindow::setupToolbar() { toolBar_ = new QToolBar(this); const QColor iconColor = color_constants::icon_color; // Create refresh action refreshAction_ = new QAction( IconUtils::createRecoloredIcon( ":/icons/ic_fluent_arrow_sync_20_regular.svg", iconColor), tr("Refresh"), this); connect(refreshAction_, &QAction::triggered, this, &<Entity>MdiWindow::reload); toolBar_->addAction(refreshAction_); // Initialize stale indicator (base class handles pulse animation) initializeStaleIndicator(refreshAction_, ":/icons/ic_fluent_arrow_sync_20_regular.svg"); // Add other actions... addAction_ = new QAction(/* ... */); // etc. } void <Entity>MdiWindow::reload() { clearStaleIndicator(); // Clear stale state before reloading model_->refresh(); }
The base class provides:
markAsStale()- Starts pulse animation and shows stale tooltipclearStaleIndicator()- Stops animation and restores normal statenormalRefreshTooltip()/staleRefreshTooltip()- Override for custom tooltips
- Pagination widget pattern
For entities with large datasets, add a
PaginationWidgetbelow the table view and connect its signals to the model:// In setupUi(): pagination_widget_ = new PaginationWidget(this); layout->addWidget(pagination_widget_); // In setupConnections(): connect(pagination_widget_, &PaginationWidget::page_size_changed, this, [this](std::uint32_t size) { model_->set_page_size(size); model_->refresh(); }); connect(pagination_widget_, &PaginationWidget::load_all_requested, this, [this]() { const auto total = model_->total_available_count(); if (total > 0 && total <= 1000) { model_->set_page_size(total); model_->refresh(); } }); connect(pagination_widget_, &PaginationWidget::page_requested, this, [this](std::uint32_t offset, std::uint32_t limit) { model_->load_page(offset, limit); }); // In onDataLoaded(): const auto loaded = model_->rowCount(); const auto total = model_->total_available_count(); pagination_widget_->update_state(loaded, total); pagination_widget_->set_load_all_enabled( loaded < static_cast<int>(total) && total > 0 && total <= 1000);
Reference:
CurrencyMdiWindowandPartyMdiWindowboth implement this pattern. - Deletion pattern
void <Entity>MdiWindow::deleteSelected() { auto selection = tableView_->selectionModel()->selectedRows(); if (selection.isEmpty()) return; // Collect identifiers QStringList ids; for (const auto& proxyIndex : selection) { auto sourceIndex = proxyModel_->mapToSource(proxyIndex); if (auto* entity = model_->get<Entity>(sourceIndex.row())) { ids << QString::fromStdString(entity->identifier()); } } // Confirm deletion auto result = MessageBoxHelper::question(this, tr("Confirm Deletion"), tr("Delete %1 selected item(s)?").arg(ids.size())); if (result != QMessageBox::Yes) return; // Async delete operation... }
Commit message
[qt] Add <Entity>MdiWindow for <entity> list view Create MDI window with table view, toolbar actions (add, edit, delete, history), stale indicator, and pagination support.
Phase 1 Checkpoint: Raise PR
At this point:
- Build and verify:
cmake --build --preset linux-clang-debug-ninja - Test manually that the model compiles and list window displays correctly.
- Commit all changes.
- Push branch and raise PR.
PR Title: [qt] Add <Entity> model and list window
PR Description:
## Summary - Add Client<Entity>Model for async data fetching with pagination - Add <Entity>MdiWindow with table view, toolbar, and stale indicator ## Test Plan - [ ] Build succeeds - [ ] List window displays entity data - [ ] Reload action refreshes data - [ ] Selection enables/disables toolbar actions appropriately
Wait for review feedback and merge before continuing to Phase 2.
Phase 2: Detail Dialog
After Phase 1 PR is merged, use feature-branch-manager to transition to Phase 2.
Suggested PR title: [qt] Add <Entity> detail dialog
Step 3: Create Detail Dialog
Create the dialog for creating, editing, and viewing entity details.
File locations
- Header:
projects/ores.qt/include/ores.qt/<Entity>DetailDialog.hpp - Implementation:
projects/ores.qt/src/<Entity>DetailDialog.cpp - UI file:
projects/ores.qt/ui/<Entity>DetailDialog.ui
Header structure
#ifndef ORES_QT_<ENTITY>_DETAIL_DIALOG_HPP #define ORES_QT_<ENTITY>_DETAIL_DIALOG_HPP #include <QToolBar> #include <memory> #include "ores.qt/DetailDialogBase.hpp" #include "ores.qt/ClientManager.hpp" #include "ores.logging/make_logger.hpp" #include "<component>/domain/<entity>.hpp" namespace Ui { class <Entity>DetailDialog; } namespace ores::qt { /** * @brief Detail dialog for viewing and editing <entity> records. * * Inherits from DetailDialogBase which provides: * - statusMessage/errorMessage signals * - requestClose() for decoupled window closing * - notifySaveSuccess() helper for consistent post-save behavior */ class <Entity>DetailDialog : public DetailDialogBase { Q_OBJECT private: inline static std::string_view logger_name = "ores.qt.<entity>_detail_dialog"; [[nodiscard]] static auto& lg() { using namespace ores::logging; static auto instance = make_logger(logger_name); return instance; } public: explicit <Entity>DetailDialog(QWidget* parent = nullptr); ~<Entity>DetailDialog() override; void setClientManager(ClientManager* clientManager); void setChangeReasonCache(ChangeReasonCache* cache); void setUsername(const std::string& username); void set<Entity>(const <component>::domain::<entity>& entity); <component>::domain::<entity> get<Entity>() const; void clearDialog(); void setReadOnly(bool readOnly, int versionNumber = 0); void setCreateMode(bool createMode); [[nodiscard]] QString identifier() const; // Version navigation (if history support) void setHistory(const std::vector<<component>::domain::<entity>>& history); void showVersionNavActions(bool visible); signals: // Note: statusMessage and errorMessage are inherited from DetailDialogBase void isDirtyChanged(bool isDirty); void <entity>Saved(const QString& id); // Emitted for both create and update void <entity>Deleted(const QString& id); void revertRequested(const <component>::domain::<entity>& entity); public slots: void save(); void markAsStale(); private slots: void onSaveClicked(); void onDeleteClicked(); void onRevertClicked(); void onFieldChanged(); void onFirstVersionClicked(); void onPrevVersionClicked(); void onNextVersionClicked(); void onLastVersionClicked(); private: void setupToolbar(); void setFieldsReadOnly(bool readOnly); void updateSaveResetButtonState(); void displayCurrentVersion(); void updateVersionNavButtonStates(); std::unique_ptr<Ui::<Entity>DetailDialog> ui_; QToolBar* toolBar_; // Toolbar actions QAction* saveAction_; QAction* deleteAction_; QAction* revertAction_; QAction* firstVersionAction_; QAction* prevVersionAction_; QAction* nextVersionAction_; QAction* lastVersionAction_; // State bool isDirty_{false}; bool isAddMode_{false}; bool isReadOnly_{false}; bool isStale_{false}; // Data ClientManager* clientManager_{nullptr}; ChangeReasonCache* changeReasonCache_{nullptr}; std::string username_; <component>::domain::<entity> current<Entity>_; std::vector<<component>::domain::<entity>> history_; int currentHistoryIndex_{0}; }; } #endif
UI file structure
All detail dialogs use a standard tabbed layout. The standard structure is:
- General tab: entity-specific form fields inside a "Basic Information" group box, plus a vertical spacer.
- (Optional entity-specific tabs: e.g. "Flag", "Formatting", "Rounding".)
- Provenance tab: a promoted
ProvenanceWidgetdisplaying the six standard provenance fields (see data_quality for field descriptions). - Button row (
Delete+Save) outside the tab widget, at the bottom.
The widget names are standardised so that the three pure-virtual overrides
in DetailDialogBase are uniform across all dialogs:
| Widget | Type | Purpose |
|---|---|---|
tabWidget |
QTabWidget |
Container for all tabs |
generalTab |
QWidget |
First tab (entity fields) |
provenanceTab |
QWidget |
Last tab (provenance fields) |
provenanceWidget |
ores::qt::ProvenanceWidget |
Promoted widget inside provenanceTab |
Minimum dialog size: 600×500.
The ProvenanceWidget is declared as a Qt Designer custom widget:
<customwidgets> <customwidget> <class>ores::qt::ProvenanceWidget</class> <extends>QWidget</extends> <header>ores.qt/ProvenanceWidget.hpp</header> <container>0</container> </customwidget> </customwidgets>
Appropriate input widgets for each field type:
QLineEditfor text fieldsQSpinBox/QDoubleSpinBoxfor numbersQCheckBoxfor booleansQComboBoxfor enums or foreign keysQDateTimeEditfor timestampsQTextEdit/QPlainTextEditfor multi-line text
Header structure additions for tabbed dialogs
Every detail dialog must implement three pure-virtual accessors inherited from
DetailDialogBase. Declare them in the protected: section:
protected: QTabWidget* tabWidget() const override; QWidget* provenanceTab() const override; ProvenanceWidget* provenanceWidget() const override;
Define them in the .cpp as one-liners after the destructor:
QTabWidget* <Entity>DetailDialog::tabWidget() const { return ui_->tabWidget; } QWidget* <Entity>DetailDialog::provenanceTab() const { return ui_->provenanceTab; } ProvenanceWidget* <Entity>DetailDialog::provenanceWidget() const { return ui_->provenanceWidget; }
Implementation patterns
- Mode handling
Use
setProvenanceEnabled(provided byDetailDialogBase) to enable or disable the Provenance tab. The tab stays visible in all modes so the tab strip remains uniform; it is merely disabled (greyed out) in create mode.Use
populateProvenanceto populate all six fields in a single call, andclearProvenanceto reset them.void <Entity>DetailDialog::setCreateMode(bool createMode) { isAddMode_ = createMode; // Primary key editable only in create mode ui_->identifierEdit->setReadOnly(!createMode); // Provenance tab disabled in create mode (no data yet) setProvenanceEnabled(!createMode); // Delete disabled in create mode deleteAction_->setEnabled(!createMode); updateSaveResetButtonState(); } void <Entity>DetailDialog::set<Entity>(const <component>::domain::<entity>& entity) { current<Entity>_ = entity; updateUiFrom<Entity>(); } void <Entity>DetailDialog::updateUiFrom<Entity>() { // Populate entity-specific fields... ui_->nameEdit->setText(QString::fromStdString(current<Entity>_.name)); // ... // Populate all six provenance fields via base class helper populateProvenance(current<Entity>_.version, current<Entity>_.modified_by, current<Entity>_.performed_by, current<Entity>_.recorded_at, current<Entity>_.change_reason_code, current<Entity>_.change_commentary); } void <Entity>DetailDialog::setReadOnly(bool readOnly, int versionNumber) { isReadOnly_ = readOnly; setFieldsReadOnly(readOnly); saveAction_->setVisible(!readOnly); deleteAction_->setVisible(!readOnly); revertAction_->setVisible(readOnly); if (readOnly && versionNumber > 0) { // Show version info in title or status } }
- Tab enable/disable rules
Tab Create mode Edit mode Read-only mode General enabled enabled enabled (fields read-only) Entity-specific tabs enabled enabled enabled (fields read-only) Provenance disabled enabled enabled - Field change tracking
void <Entity>DetailDialog::onFieldChanged() { if (isReadOnly_) return; isDirty_ = true; emit isDirtyChanged(true); updateSaveResetButtonState(); }
- Async save pattern
#include "ores.qt/ExceptionHelper.hpp" // Define a result struct for the async operation struct SaveResult { bool success; QString error_message; QString error_details; }; void <Entity>DetailDialog::onSaveClicked() { if (!clientManager_ || !clientManager_->isConnected()) { emit errorMessage(tr("Not connected to server")); return; } // Change reason dialog (amend mode only — not required for create) std::string change_reason_code; std::string change_commentary; if (!isAddMode_) { if (!changeReasonCache_ || !changeReasonCache_->isLoaded()) { emit errorMessage(tr("Change reasons not loaded. Please try again.")); return; } auto reasons = changeReasonCache_->getReasonsForAmend( std::string{reason::categories::common}); if (reasons.empty()) { emit errorMessage(tr("No change reasons available. Please contact administrator.")); return; } ChangeReasonDialog dlg(reasons, ChangeReasonDialog::OperationType::Amend, isDirty_, this); if (dlg.exec() != QDialog::Accepted) return; change_reason_code = dlg.selectedReasonCode(); change_commentary = dlg.commentary(); } auto entity = get<Entity>(); QPointer<<Entity>DetailDialog> self = this; QFuture<SaveResult> future = QtConcurrent::run( [self, entity, change_reason_code, change_commentary]() -> SaveResult { return exception_helper::wrap_async_fetch<SaveResult>([&]() -> SaveResult { if (!self) { return {.success = false, .error_message = "Dialog closed", .error_details = {}}; } // Build and send request... auto response = /* deserialize response */; if (!response->success) { return {.success = false, .error_message = QString::fromStdString(response->message), .error_details = {}}; } return {.success = true, .error_message = {}, .error_details = {}}; }, "<entity>"); }); auto* watcher = new QFutureWatcher<SaveResult>(self); connect(watcher, &QFutureWatcher<SaveResult>::finished, self, [self, watcher]() { const auto result = watcher->result(); watcher->deleteLater(); if (result.success) { // Emit entity-specific signal first emit self-><entity>Saved(self->identifier()); // Then use base class helper to emit status and close dialog self->notifySaveSuccess( tr("<Entity> '%1' saved").arg(self->identifier())); } else { BOOST_LOG_SEV(lg(), error) << "Save failed: " << result.error_message.toStdString(); emit self->errorMessage(result.error_message); MessageBoxHelper::critical(self, tr("Save Error"), result.error_message, result.error_details); } }); watcher->setFuture(future); }
- Async delete pattern with change reason
struct DeleteResult { bool success; std::string message; }; void <Entity>DetailDialog::onDeleteClicked() { if (!clientManager_ || !clientManager_->isConnected()) { MessageBoxHelper::warning(this, "Disconnected", "Cannot delete <entity> while disconnected from server."); return; } // Confirmation dialog auto reply = MessageBoxHelper::question(this, "Delete <Entity>", QString("Are you sure you want to delete '%1'?").arg(identifier()), QMessageBox::Yes | QMessageBox::No); if (reply != QMessageBox::Yes) return; // Change reason dialog for delete std::string change_reason_code; std::string change_commentary; if (!changeReasonCache_ || !changeReasonCache_->isLoaded()) { emit errorMessage(tr("Change reasons not loaded. Please try again.")); return; } auto reasons = changeReasonCache_->getReasonsForDelete( std::string{reason::categories::common}); if (reasons.empty()) { emit errorMessage(tr("No change reasons available. Please contact administrator.")); return; } ChangeReasonDialog dlg(reasons, ChangeReasonDialog::OperationType::Delete, false, this); if (dlg.exec() != QDialog::Accepted) return; change_reason_code = dlg.selectedReasonCode(); change_commentary = dlg.commentary(); QPointer<<Entity>DetailDialog> self = this; auto task = [self, id = current<Entity>_.id, change_reason_code, change_commentary]() -> DeleteResult { if (!self || !self->clientManager_) return {.success = false, .message = "Dialog closed"}; // Build and send delete request... return {.success = response->success, .message = response->message}; }; auto* watcher = new QFutureWatcher<DeleteResult>(self); connect(watcher, &QFutureWatcher<DeleteResult>::finished, self, [self, watcher]() { auto result = watcher->result(); watcher->deleteLater(); if (result.success) { emit self-><entity>Deleted(self->identifier()); self->requestClose(); } else { QString errorMsg = QString::fromStdString(result.message); emit self->errorMessage(errorMsg); MessageBoxHelper::critical(self, "Delete Failed", errorMsg); } }); QFuture<DeleteResult> future = QtConcurrent::run(task); watcher->setFuture(future); }
- Required includes for change reason support
#include "ores.qt/ChangeReasonCache.hpp" #include "ores.qt/ChangeReasonDialog.hpp" #include "ores.dq/domain/change_reason_constants.hpp" namespace ores::qt { namespace reason = dq::domain::change_reason_constants;
Commit message
[qt] Add <Entity>DetailDialog for <entity> editing Create detail dialog with create/edit/view-only modes, version navigation, and async save/delete operations with change reason tracking.
Step 4: Wire Detail Dialog to List Window
Update the MDI window to open detail dialogs.
Update MDI Window
Add temporary direct handling (will be moved to controller in Phase 4):
void <Entity>MdiWindow::onRowDoubleClicked(const QModelIndex& index) { auto sourceIndex = proxyModel_->mapToSource(index); if (auto* entity = model_->get<Entity>(sourceIndex.row())) { emit show<Entity>Details(*entity); } }
Commit message
[qt] Wire <Entity>DetailDialog to list window Connect double-click and edit action to emit show details signal.
Phase 2 Checkpoint: Raise PR
At this point:
- Build and verify:
cmake --build --preset linux-clang-debug-ninja - Test that detail dialog opens and displays entity data.
- Test create/edit/view-only modes.
- Test save and delete operations.
- Commit all changes.
- Push branch and raise PR.
PR Title: [qt] Add <Entity> detail dialog
PR Description:
## Summary - Add <Entity>DetailDialog with create/edit/view-only modes - Add toolbar with save/delete/revert actions - Add version navigation for historical viewing - Wire detail dialog to list window double-click ## Test Plan - [ ] Build succeeds - [ ] Detail dialog opens from list view - [ ] Create mode allows new entity creation - [ ] Edit mode saves changes - [ ] View-only mode disables editing
Wait for review feedback and merge before continuing to Phase 3.
Phase 3: History Dialog
After Phase 2 PR is merged, use feature-branch-manager to transition to Phase 3.
Suggested PR title: [qt] Add <Entity> history dialog
Step 5: Create History Dialog
Create the dialog for viewing entity version history.
File locations
- Header:
projects/ores.qt/include/ores.qt/<Entity>HistoryDialog.hpp - Implementation:
projects/ores.qt/src/<Entity>HistoryDialog.cpp - UI file:
projects/ores.qt/ui/<Entity>HistoryDialog.ui
Header structure
#ifndef ORES_QT_<ENTITY>_HISTORY_DIALOG_HPP #define ORES_QT_<ENTITY>_HISTORY_DIALOG_HPP #include <QWidget> #include <QToolBar> #include <memory> #include "ores.qt/ClientManager.hpp" #include "ores.logging/make_logger.hpp" #include "<component>/domain/<entity>.hpp" namespace Ui { class <Entity>HistoryDialog; } namespace ores::qt { class <Entity>HistoryDialog : public QWidget { Q_OBJECT private: inline static std::string_view logger_name = "ores.qt.<entity>_history_dialog"; [[nodiscard]] static auto& lg() { using namespace ores::logging; static auto instance = make_logger(logger_name); return instance; } public: explicit <Entity>HistoryDialog(const QString& identifier, ClientManager* clientManager, QWidget* parent = nullptr); ~<Entity>HistoryDialog() override; void loadHistory(); void markAsStale(); QSize sizeHint() const override; [[nodiscard]] const QString& identifier() const { return identifier_; } signals: void statusChanged(const QString& message); void errorOccurred(const QString& error_message); void openVersionRequested(const <component>::domain::<entity>& entity, int versionNumber); void revertVersionRequested(const <component>::domain::<entity>& entity); private slots: void onVersionSelected(int row); void onHistoryLoaded(); void onHistoryLoadError(const QString& error); void onOpenClicked(); void onRevertClicked(); void onReloadClicked(); private: using DiffResult = QVector<QPair<QString, QPair<QString, QString>>>; void setupToolbar(); void displayChangesTab(int versionIndex); void displayFullDetailsTab(int versionIndex); DiffResult calculateDiff(const <component>::domain::<entity>& current, const <component>::domain::<entity>& previous); void updateButtonStates(); int selectedVersionIndex() const; const QIcon& getHistoryIcon() const; std::unique_ptr<Ui::<Entity>HistoryDialog> ui_; QToolBar* toolBar_; QAction* reloadAction_; QAction* openAction_; QAction* revertAction_; ClientManager* clientManager_; QString identifier_; std::vector<<component>::domain::<entity>> history_; }; } #endif
UI file structure
Create a Qt Designer .ui file with:
- Toolbar area at top.
- Splitter with:
- Left: Version list table (version number, timestamp, recorded by).
- Right: Tab widget with:
- "Changes" tab: Table showing field/old value/new value.
- "Full Details" tab: Complete entity fields for selected version.
Implementation patterns
- Diff calculation
<Entity>HistoryDialog::DiffResult <Entity>HistoryDialog::calculateDiff( const <component>::domain::<entity>& current, const <component>::domain::<entity>& previous) { DiffResult result; #define CHECK_DIFF_STRING(FIELD_NAME, FIELD) \ if (current.FIELD != previous.FIELD) { \ result.append({FIELD_NAME, \ {QString::fromStdString(previous.FIELD), \ QString::fromStdString(current.FIELD)}}); \ } #define CHECK_DIFF_BOOL(FIELD_NAME, FIELD) \ if (current.FIELD != previous.FIELD) { \ result.append({FIELD_NAME, \ {previous.FIELD ? "Yes" : "No", \ current.FIELD ? "Yes" : "No"}}); \ } // Apply macros for each field CHECK_DIFF_STRING("Name", name); CHECK_DIFF_BOOL("Active", is_active); // ... etc #undef CHECK_DIFF_STRING #undef CHECK_DIFF_BOOL return result; }
- Async history loading
#include "ores.qt/ExceptionHelper.hpp" // Define a result struct for consistency with other async patterns struct HistoryResult { bool success; std::vector<<component>::domain::<entity>> entries; QString error_message; QString error_details; }; void <Entity>HistoryDialog::loadHistory() { QPointer<<Entity>HistoryDialog> self = this; QFuture<HistoryResult> future = QtConcurrent::run([self]() -> HistoryResult { return exception_helper::wrap_async_fetch<HistoryResult>([&]() -> HistoryResult { if (!self || !self->clientManager_) { return {.success = false, .entries = {}, .error_message = "Dialog closed or no client", .error_details = {}}; } // Build and send history request... return {.success = true, .entries = std::move(history_entries), .error_message = {}, .error_details = {}}; }, "<entity> history"); }); auto* watcher = new QFutureWatcher<HistoryResult>(self); connect(watcher, &QFutureWatcher<HistoryResult>::finished, self, [self, watcher]() { const auto result = watcher->result(); watcher->deleteLater(); if (result.success) { self->history_ = std::move(result.entries); self->onHistoryLoaded(); } else { BOOST_LOG_SEV(lg(), error) << "History load failed: " << result.error_message.toStdString(); MessageBoxHelper::critical(self, tr("Load Error"), result.error_message, result.error_details); } }); watcher->setFuture(future); }
Commit message
[qt] Add <Entity>HistoryDialog for <entity> version history Create history dialog with version list, changes diff view, full details tab, and revert functionality.
Step 6: Wire History Dialog to List Window
Update the MDI window to open history dialogs.
Update MDI Window
void <Entity>MdiWindow::showHistory() { auto selection = tableView_->selectionModel()->selectedRows(); if (selection.size() != 1) return; auto sourceIndex = proxyModel_->mapToSource(selection.first()); if (auto* entity = model_->get<Entity>(sourceIndex.row())) { emit show<Entity>History(QString::fromStdString(entity->identifier())); } }
Commit message
[qt] Wire <Entity>HistoryDialog to list window Connect history action to emit show history signal.
Phase 3 Checkpoint: Raise PR
At this point:
- Build and verify:
cmake --build --preset linux-clang-debug-ninja - Test that history dialog opens and shows version list.
- Test version selection shows diff and full details.
- Test open and revert functionality.
- Commit all changes.
- Push branch and raise PR.
PR Title: [qt] Add <Entity> history dialog
PR Description:
## Summary - Add <Entity>HistoryDialog with version list and diff view - Add toolbar with reload/open/revert actions - Add changes tab showing field differences between versions - Add full details tab showing complete entity state - Wire history dialog to list window ## Test Plan - [ ] Build succeeds - [ ] History dialog opens from list view - [ ] Version list shows all versions - [ ] Changes tab shows differences correctly - [ ] Full details tab shows complete entity - [ ] Revert creates new version with old data
Wait for review feedback and merge before continuing to Phase 4.
Phase 4: Controller and Integration
After Phase 3 PR is merged, use feature-branch-manager to transition to Phase 4.
Suggested PR title: [qt] Integrate <Entity> controller and update diagrams
Step 7: Create Controller
Create the controller that coordinates windows and handles events.
File locations
- Header:
projects/ores.qt/include/ores.qt/<Entity>Controller.hpp - Implementation:
projects/ores.qt/src/<Entity>Controller.cpp
Header structure
The controller inherits from EntityController and:
- Passes the event name to the base class constructor for automatic subscription
- Overrides
listWindow()to return the list window for stale marking - Does NOT need to implement
onNotificationReceived(handled by base class)
#ifndef ORES_QT_<ENTITY>_CONTROLLER_HPP #define ORES_QT_<ENTITY>_CONTROLLER_HPP #include <QPointer> #include "ores.qt/EntityController.hpp" #include "ores.qt/DetachableMdiSubWindow.hpp" #include "ores.logging/make_logger.hpp" #include "<component>/domain/<entity>.hpp" namespace ores::qt { class <Entity>MdiWindow; class <Entity>Controller : public EntityController { Q_OBJECT private: inline static std::string_view logger_name = "ores.qt.<entity>_controller"; [[nodiscard]] static auto& lg() { using namespace ores::logging; static auto instance = make_logger(logger_name); return instance; } public: <Entity>Controller( QMainWindow* mainWindow, QMdiArea* mdiArea, ClientManager* clientManager, const QString& username, ChangeReasonCache* changeReasonCache, QObject* parent = nullptr); void showListWindow() override; void closeAllWindows() override; void reloadListWindow() override; protected: // Return list window for base class notification handling EntityListMdiWindow* listWindow() const override; private slots: void onAddNewRequested(); void onShow<Entity>Details(const <component>::domain::<entity>& entity); void onShow<Entity>History(const QString& id); void on<Entity>Deleted(const QString& id); void onOpenHistoryVersion(const <component>::domain::<entity>& entity, int versionNumber); void onRevert<Entity>(const <component>::domain::<entity>& entity); private: void showDetailWindow(const <component>::domain::<entity>* entity, bool createMode, bool readOnly = false, int versionNumber = 0); ChangeReasonCache* changeReasonCache_; QPointer<<Entity>MdiWindow> listWindow_; QPointer<DetachableMdiSubWindow> listMdiSubWindow_; }; } #endif
Also add the forward declaration before the class:
class ChangeReasonCache;
Implementation patterns
- Constructor - event subscription via base class
Important: The event subscription requires the eventing infrastructure to be in place:
- Database notification trigger exists (see sql-schema-creator skill)
- Event type is defined with
event_traitsspecialization - Event is registered in comms service
application.cpp
For details, see the "Eventing Infrastructure" section in the binary-protocol-developer skill.
The
EntityControllerbase class handles all event subscription/unsubscription when you pass the event name to the constructor:#include "ores.eventing/domain/event_traits.hpp" #include "<component>/eventing/<entity>_changed_event.hpp" namespace ores::qt { using namespace ores::logging; namespace { // Get the event name from the event traits constexpr std::string_view <entity>_event_name = eventing::domain::event_traits< <component>::eventing::<entity>_changed_event>::name; } <Entity>Controller::<Entity>Controller( QMainWindow* mainWindow, QMdiArea* mdiArea, ClientManager* clientManager, const QString& username, ChangeReasonCache* changeReasonCache, QObject* parent) : EntityController(mainWindow, mdiArea, clientManager, username, <entity>_event_name, parent), // Pass event name to base changeReasonCache_(changeReasonCache), listWindow_(nullptr), listMdiSubWindow_(nullptr) { BOOST_LOG_SEV(lg(), debug) << "<Entity>Controller created"; } // Override listWindow() so base class can call markAsStale() EntityListMdiWindow* <Entity>Controller::listWindow() const { return listWindow_; }
The base class:
- Subscribes to events on connect/reconnect
- Unsubscribes in destructor
- Handles
onNotificationReceivedand callslistWindow()->markAsStale()
For controllers that don't need events, pass empty string_view
{}:: EntityController(mainWindow, mdiArea, clientManager, username, {}, parent) - Show list window
void <Entity>Controller::showListWindow() { // Reuse existing window if available if (listMdiSubWindow_) { bring_window_to_front(listMdiSubWindow_); return; } BOOST_LOG_SEV(lg(), info) << "Creating new <entity> MDI window"; const QColor iconColor = color_constants::icon_color; // Create widget listWindow_ = new <Entity>MdiWindow(clientManager_, username_, nullptr); // Connect signals connect(listWindow_, &<Entity>MdiWindow::statusChanged, this, &<Entity>Controller::statusMessage); connect(listWindow_, &<Entity>MdiWindow::errorOccurred, this, &<Entity>Controller::errorMessage); connect(listWindow_, &<Entity>MdiWindow::addNewRequested, this, &<Entity>Controller::onAddNewRequested); connect(listWindow_, &<Entity>MdiWindow::show<Entity>Details, this, &<Entity>Controller::onShow<Entity>Details); connect(listWindow_, &<Entity>MdiWindow::show<Entity>History, this, &<Entity>Controller::onShow<Entity>History); // Wrap in MDI sub-window listMdiSubWindow_ = new DetachableMdiSubWindow(mainWindow_); listMdiSubWindow_->setAttribute(Qt::WA_DeleteOnClose); listMdiSubWindow_->setWidget(listWindow_); listMdiSubWindow_->setWindowTitle(tr("<Entity>s")); listMdiSubWindow_->setWindowIcon(IconUtils::createRecoloredIcon( ":/icons/ic_fluent_<icon>_20_regular.svg", iconColor)); register_detachable_window(listMdiSubWindow_); // Track destruction - use QPointer for safety connect(listMdiSubWindow_, &QObject::destroyed, this, [self = QPointer<<Entity>Controller>(this)]() { if (!self) return; self->listWindow_ = nullptr; self->listMdiSubWindow_ = nullptr; }); mdiArea_->addSubWindow(listMdiSubWindow_); listMdiSubWindow_->adjustSize(); listMdiSubWindow_->show(); listWindow_->reload(); } void <Entity>Controller::closeAllWindows() { BOOST_LOG_SEV(lg(), debug) << "closeAllWindows called"; // Close all managed windows QList<QString> keys = managed_windows_.keys(); for (const QString& key : keys) { if (auto* window = managed_windows_.value(key)) { window->close(); } } managed_windows_.clear(); listWindow_ = nullptr; listMdiSubWindow_ = nullptr; } void <Entity>Controller::reloadListWindow() { if (listWindow_) { listWindow_->reload(); } }
- Showing detail and history windows
Use the
show_managed_windowhelper fromEntityControllerto present detail and history windows. This helper handles adding to MDI, removing the maximize button, sizing, and detaching if the list window is detached:void <Entity>Controller::showDetailWindow( const <component>::domain::<entity>* entity, bool createMode, bool readOnly, int versionNumber) { const QString windowKey = build_window_key("details", entity ? QString::fromStdString(entity->identifier()) : "new"); if (try_reuse_window(windowKey)) { return; } auto* detailDialog = new <Entity>DetailDialog(mainWindow_); detailDialog->setClientManager(clientManager_); detailDialog->setChangeReasonCache(changeReasonCache_); detailDialog->setUsername(username_.toStdString()); detailDialog->setCreateMode(createMode); if (entity) { detailDialog->set<Entity>(*entity); } // Connect status/error signals connect(detailDialog, &<Entity>DetailDialog::statusMessage, this, &<Entity>Controller::statusMessage); connect(detailDialog, &<Entity>DetailDialog::errorMessage, this, &<Entity>Controller::errorMessage); // Connect entity-specific signals - use handleEntitySaved/handleEntityDeleted // for centralized reload control. Use QPointer for safety in lambda captures. connect(detailDialog, &<Entity>DetailDialog::<entity>Saved, this, [self = QPointer<<Entity>Controller>(this)](const QString& id) { if (!self) return; BOOST_LOG_SEV(lg(), info) << "<Entity> saved: " << id.toStdString(); self->handleEntitySaved(); // Respects autoReloadOnSave_ flag }); connect(detailDialog, &<Entity>DetailDialog::<entity>Deleted, this, [self = QPointer<<Entity>Controller>(this), windowKey](const QString& id) { if (!self) return; BOOST_LOG_SEV(lg(), info) << "<Entity> deleted: " << id.toStdString(); self->handleEntityDeleted(); // Respects autoReloadOnSave_ flag }); auto* detailWindow = new DetachableMdiSubWindow(mainWindow_); detailWindow->setAttribute(Qt::WA_DeleteOnClose); detailWindow->setWidget(detailDialog); detailWindow->setWindowTitle(createMode ? tr("New <Entity>") : tr("<Entity>: %1").arg(/* identifier */)); detailWindow->setWindowIcon(/* ... */); register_detachable_window(detailWindow); connect(detailWindow, &QObject::destroyed, this, [self = QPointer<<Entity>Controller>(this), windowKey]() { if (!self) return; self->managed_windows_.remove(windowKey); }); managed_windows_[windowKey] = detailWindow; // Wire dialog close signal to window close connect_dialog_close(detailDialog, detailWindow); // Use the helper to show the window (handles MDI add, sizing, detach) show_managed_window(detailWindow, listMdiSubWindow_); }
The
handleEntitySaved()andhandleEntityDeleted()methods are defined inEntityControllerand respect theautoReloadOnSave_flag. By default, auto-reload is disabled. Controllers should NOT calllistWindow_->reload()directly; instead, use these centralized methods for consistent behavior.The
show_managed_windowmethod signature:void show_managed_window(DetachableMdiSubWindow* window, DetachableMdiSubWindow* referenceWindow = nullptr, QPoint offset = QPoint(30, 30));
window: The window to showreferenceWindow: Optional window to follow detach state from (typicallylistWindow_)offset: Position offset when detaching (default 30,30, use 60,60 for nested windows)
- QPointer safety in lambda captures
When using lambdas in Qt
connect()calls, always useQPointerto safely capturethis. This prevents crashes if the controller is destroyed while a signal is still pending:// CORRECT: Use QPointer for safety connect(sender, &Sender::signal, this, [self = QPointer<MyController>(this)](const QString& arg) { if (!self) return; // Guard against destroyed controller BOOST_LOG_SEV(lg(), info) << "Signal received"; self->handleSomething(); emit self->statusMessage("Done"); }); // WRONG: Raw this capture can crash if controller is destroyed connect(sender, &Sender::signal, this, [this](const QString& arg) { handleSomething(); // Crash if 'this' was deleted! });
Rules for QPointer usage:
- Replace
[this]with[self = QPointer<ControllerClass>(this)] - For additional captures:
[self = QPointer<...>(this), key, otherVar] - Add
if (!self) return;as the first line in the lambda - Replace
this->withself-> - Replace bare member access (
member_) withself->member_ - Replace
emit signal(...)withemit self->signal(...)
- Replace
- Notification handling (automatic via base class)
Notification handling is automatic when you:
- Pass the event name to the
EntityControllerbase class constructor - Override
listWindow()to return your list window
The base class
EntityController::onNotificationReceived():- Filters notifications by the event name you provided
- Calls
listWindow()->markAsStale()when matching notifications arrive - Logs the notification receipt
No additional code is needed in derived controllers for basic stale marking.
- Pass the event name to the
Commit message
[qt] Add <Entity>Controller for <entity> window management Create controller with list window, detail dialogs, history dialogs, and server notification handling.
Step 8: MainWindow Integration
Integrate the controller into the main window.
Files to modify
projects/ores.qt/include/ores.qt/MainWindow.hppprojects/ores.qt/src/MainWindow.cppprojects/ores.qt/ui/MainWindow.ui
Header changes
Add to MainWindow.hpp:
// Forward declaration class <Entity>Controller; // Member variable <Entity>Controller* <entity>Controller_;
UI changes
Add to MainWindow.ui:
- Menu action:
Action<Entity>s - Set appropriate icon (following icon-guidelines skill)
- Set keyboard shortcut if appropriate
Implementation changes
In MainWindow.cpp constructor:
// Create controller — pass changeReasonCache_ so dialogs can show // the change reason dialog before save (amend) and delete. <entity>Controller_ = new <Entity>Controller( this, mdiArea_, clientManager_, username_, changeReasonCache_, this); // Connect signals from controller to MainWindow connect(<entity>Controller_, &<Entity>Controller::detachableWindowCreated, this, &MainWindow::onDetachableWindowCreated); connect(<entity>Controller_, &<Entity>Controller::detachableWindowDestroyed, this, &MainWindow::onDetachableWindowDestroyed); // Connect signals connect(<entity>Controller_, &<Entity>Controller::statusMessage, this, [this](const QString& msg) { ui_->statusbar->showMessage(msg, 5000); }); connect(<entity>Controller_, &<Entity>Controller::errorMessage, this, [this](const QString& err) { ui_->statusbar->showMessage(err, 5000); }); // Connect menu action connect(ui_->Action<Entity>s, &QAction::triggered, <entity>Controller_, &<Entity>Controller::showListWindow); // Set menu icon (using icon-guidelines skill for selection) ui_->Action<Entity>s->setIcon(IconUtils::createRecoloredIcon( ":/icons/ic_fluent_<icon>_20_regular.svg", iconColor)); // Enable/disable based on connection state ui_->Action<Entity>s->setEnabled(isConnected);
Commit message
[qt] Integrate <Entity>Controller into MainWindow Add menu action and controller for <entity> management in main window.
Step 9: Update UML Diagrams
Use the plantuml-class-modeler skill to update the ores.qt component diagrams.
Files to update
projects/ores.qt/modeling/ores_qt_classes.puml(or similar)
Classes to add
Add the following classes to the diagram:
Client<Entity>Model- inherits fromQAbstractTableModel<Entity>MdiWindow- inherits fromQWidget<Entity>DetailDialog- inherits fromQWidget<Entity>HistoryDialog- inherits fromQWidget<Entity>Controller- inherits fromEntityController
Relationships to show
- Controller creates/manages MdiWindow, DetailDialog, HistoryDialog
- MdiWindow uses Client<Entity>Model
- All dialogs use ClientManager
Commit message
[qt] Update UML diagrams for <Entity> components Add class diagrams for Client<Entity>Model, <Entity>MdiWindow, <Entity>DetailDialog, <Entity>HistoryDialog, and <Entity>Controller.
Phase 4 Checkpoint: Raise PR
At this point:
- Build and verify:
cmake --build --preset linux-clang-debug-ninja - Run full end-to-end test of the entity.
- Verify UML diagrams generate correctly.
- Commit all changes.
- Push branch and raise PR.
PR Title: [qt] Integrate <Entity> controller and update diagrams
PR Description:
## Summary - Add <Entity>Controller coordinating all windows - Integrate controller into MainWindow with menu action - Add server notification handling for real-time updates - Update UML class diagrams for ores.qt component ## Test Plan - [ ] Build succeeds - [ ] Menu action opens entity list window - [ ] All CRUD operations work end-to-end - [ ] Server notifications trigger stale indicators - [ ] UML diagrams generate correctly
Common patterns reference
Icon paths by entity type
Use the icon-guidelines skill for icon selection. Common patterns:
| Entity Type | Icon Path |
|---|---|
| Currency | ic_fluent_currency_dollar_euro_20_regular.svg |
| Country | ic_fluent_globe_20_regular.svg |
| Account | ic_fluent_person_accounts_20_regular.svg |
| Role | ic_fluent_lock_closed_20_regular.svg |
| ChangeReason | ic_fluent_note_edit_20_regular.svg |
| ChangeReasonCategory | ic_fluent_tag_20_regular.svg |
| FeatureFlag | ic_fluent_flag_20_regular.svg |
| History | ic_fluent_history_20_regular.svg |
| Reload | ic_fluent_arrow_clockwise_16_regular.svg |
| Add | ic_fluent_add_20_regular.svg |
| Edit | ic_fluent_edit_20_regular.svg |
| Delete | ic_fluent_delete_20_regular.svg |
| Save | ic_fluent_save_20_regular.svg |
| Revert | ic_fluent_arrow_rotate_counterclockwise_20_regular.svg |
Window size hints
Pass the default size as the 5th argument to initializeTableSettings().
The base class sizeHint() returns this value (or the saved window size if the
user has resized it previously).
| Window Type | Size | Notes |
|---|---|---|
| List (standard) | 900x400 | Default for most entities |
| List (large) | 1000x600 | Currencies, countries |
| List (small) | 700x400 | Roles |
| Detail dialog | 600x500 | |
| History dialog | 900x600 |
Color constants
Use color_constants from ColorConstants.hpp:
icon_color- Light gray (220, 220, 220) for toolbar iconsstale_indicator- Orange (255, 165, 0) for stale data indicator
Item delegates must reference the model's Column enum
When creating a custom ItemDelegate, never hardcode column indices. Always
reference the model's public Column enum. This ensures that adding, removing,
or reordering columns in the model is automatically reflected in the delegate at
compile time.
#include "ores.qt/Client<Entity>Model.hpp" namespace ores::qt { using Column = Client<Entity>Model::Column; void <Entity>ItemDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); // CORRECT: Use enum values for column checks if (index.column() == Column::Status) { drawBadge(painter, opt.rect, /* ... */); return; } switch (index.column()) { case Column::Name: opt.displayAlignment = Qt::AlignLeft | Qt::AlignVCenter; break; case Column::Version: opt.font = monospaceFont_; opt.displayAlignment = Qt::AlignCenter; break; // ... } }
Anti-pattern - do not duplicate column indices as local constants:
// WRONG: These go stale when the model's Column enum changes. constexpr int status_column_index = 2; constexpr int locked_column_index = 3;
Badge colors for item delegates
When creating custom item delegates with badges, use badge_colors from
ColorConstants.hpp:
#include "ores.qt/ColorConstants.hpp" // In delegate implementation: using bc = badge_colors; // Boolean/enabled states bc::enabled // Green for enabled/yes/active bc::disabled // Gray for disabled/no/inactive bc::yes // Alias for enabled bc::no // Alias for disabled // Status indicators bc::online // Green for online bc::recent // Blue for recent activity bc::old // Amber for old/stale bc::never // Gray for never bc::locked // Red for locked bc::unlocked // Gray for unlocked // Data quality dimensions bc::origin_primary, bc::origin_derived bc::nature_actual, bc::nature_estimated, bc::nature_simulated bc::treatment_raw, bc::treatment_cleaned, bc::treatment_enriched // Common text color bc::text // White (255, 255, 255) bc::default_bg // Gray fallback
Badge font scaling
For proper high-DPI support, derive badge fonts from the view's font rather than hardcoding point sizes:
void MyItemDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { QStyleOptionViewItem opt = option; initStyleOption(&opt, index); // Derive badge font from view font QFont badgeFont = opt.font; badgeFont.setPointSize(qRound(badgeFont.pointSize() * 0.8)); badgeFont.setBold(true); // Pass font to badge drawing helper drawBadge(painter, rect, text, bgColor, badge_colors::text, badgeFont); }
This ensures badges scale correctly with system font settings and high-DPI
displays. Do NOT store a hardcoded badgeFont_ member.
Populating combo boxes and columns with external entity data
Entities often have fields that reference other entities (e.g. a party's
business_center_code references a business centre). These fields need combo
boxes in detail dialogs and may need enriched display (e.g. flag icons) in list
models. The key principle is: centralise the data fetching in LookupFetcher
so multiple consumers share a single implementation.
Adding external data to LookupFetcher
LookupFetcher (include/ores.qt/LookupFetcher.hpp, src/LookupFetcher.cpp)
provides synchronous fetch functions designed to be called from
QtConcurrent::run. There are two patterns depending on the consumer:
- For detail dialog combo boxes: Add the codes to
lookup_resultand fetch them inside the existingfetch_party_lookups()orfetch_tenant_lookups(). The dialog'spopulateLookups()already runs these asynchronously and populates combos from the result. - For list model enrichment (e.g. icon lookups, display name resolution): Add a standalone function that returns the mapping. This keeps it decoupled from the lookup result struct.
Both patterns must use process_authenticated_request rather than the low-level
sendRequest / frame construction pattern.
Example - adding a standalone fetch function:
// In LookupFetcher.hpp: std::unordered_map<std::string, std::string> fetch_business_centre_image_map(ClientManager* cm); // In LookupFetcher.cpp: std::unordered_map<std::string, std::string> fetch_business_centre_image_map(ClientManager* cm) { std::unordered_map<std::string, std::string> mapping; if (!cm) return mapping; refdata::messaging::get_business_centres_request request; request.limit = 1000; auto response = cm->process_authenticated_request(std::move(request)); if (response) { for (const auto& bc : response->business_centres) { std::string image_id_str; if (bc.image_id) image_id_str = boost::uuids::to_string(*bc.image_id); mapping.emplace(bc.code, std::move(image_id_str)); } } return mapping; }
Using external data in detail dialog combo boxes
When a detail dialog has a field that references another entity:
- Use
QComboBox(seteditable=trueif free text should be allowed) in the.uifile. - Add the codes to the
lookup_resultstruct if not already present. - Fetch them inside the appropriate
fetch_*_lookups()function. - Populate the combo in the dialog's
populateLookups()callback:
self->ui_->businessCenterCombo->clear(); for (const auto& code : result.business_centre_codes) { self->ui_->businessCenterCombo->addItem(QString::fromStdString(code)); }
- Use
setCurrentText()/currentText()for reading/writing the value. - Use
setEnabled(!readOnly)instead ofsetReadOnly()for read-only mode.
Reference: PartyDetailDialog and CounterpartyDetailDialog (business centre
combo).
Using external data in list model columns
When a list model needs to display enriched data from another entity (e.g. country flag icons from business centres):
- Add the shared fetch function to
LookupFetcher(see above). - In the model, call the shared function via
QtConcurrent::runand store the result. The async boilerplate stays in the model because it needs to update model-specific state and emitdataChanged:
void Client<Entity>Model::fetch_external_data() { if (!clientManager_ || !clientManager_->isConnected()) return; using MapType = std::unordered_map<std::string, std::string>; QPointer<Client<Entity>Model> self = this; auto* watcher = new QFutureWatcher<MapType>(this); connect(watcher, &QFutureWatcher<MapType>::finished, this, [self, watcher]() { auto mapping = watcher->result(); watcher->deleteLater(); if (!self || mapping.empty()) return; self->code_to_value_ = std::move(mapping); if (!self->entries_.empty()) { emit self->dataChanged(self->index(0, TargetColumn), self->index(self->rowCount() - 1, TargetColumn), {Qt::DecorationRole}); } }); watcher->setFuture(QtConcurrent::run( [cm = clientManager_]() { return fetch_the_shared_function(cm); })); }
Important: Never duplicate the server-fetch logic across models. The async
watcher wrapper is model-specific, but the data fetching must be in
LookupFetcher.
Reference: ClientPartyModel and ClientCounterpartyModel (flag icons from
business centres via fetch_business_centre_image_map).
Note on server limits: The server enforces a maximum request limit (currently 1000). Do not exceed this. If an entity has more than 1000 records, you will need pagination in the fetch function.
Related skills
- Icon Guidelines - For selecting appropriate icons
- PlantUML Class Modeler - For updating UML class diagrams
- feature-branch-manager - For transitioning between phases
- Domain Type Creator - For creating the underlying domain type
- Binary Protocol Developer - For creating messaging protocol