diff --git a/pglab/CMakeLists.txt b/pglab/CMakeLists.txt index 8f3e85d..8fd2b22 100644 --- a/pglab/CMakeLists.txt +++ b/pglab/CMakeLists.txt @@ -31,6 +31,7 @@ set(PGLAB_SOURCES crud/CrudModel.cpp crud/CrudTab.cpp crud/DataGridWidget.cpp + crud/RecordPanel.cpp querytool/QueryExplainModel.cpp querytool/QueryParamListController.cpp querytool/QueryTool.cpp diff --git a/pglab/crud/CrudModel.h b/pglab/crud/CrudModel.h index f2c8f78..3fbe91e 100644 --- a/pglab/crud/CrudModel.h +++ b/pglab/crud/CrudModel.h @@ -31,6 +31,9 @@ public: /// Show an arbitrary query result, cells are editable where the result maps them to a table with a primary key void setResult(std::shared_ptr db, std::shared_ptr result); + /// Number of grid columns in front of the result columns (the row number column) + static constexpr int PreColumnCount = 1; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; @@ -62,10 +65,6 @@ signals: private: - /// Number of grid columns in front of the result columns (the row number column) - static constexpr int PreColumnCount = 1; - - class ColumnSort { public: enum Direction { Ascending, Descending }; diff --git a/pglab/crud/DataGridWidget.cpp b/pglab/crud/DataGridWidget.cpp index 65708f7..bd9e2ae 100644 --- a/pglab/crud/DataGridWidget.cpp +++ b/pglab/crud/DataGridWidget.cpp @@ -1,10 +1,13 @@ #include "DataGridWidget.h" #include "CrudModel.h" #include "CustomDataRole.h" +#include "RecordPanel.h" #include "ResultTableModelUtil.h" #include "util/PgLabItemDelegate.h" #include +#include #include +#include #include #include #include @@ -36,12 +39,28 @@ DataGridWidget::DataGridWidget(QWidget *parent) m_toolbar->addAction(m_actRevert); connect(m_actRevert, &QAction::triggered, this, &DataGridWidget::onRevert); - m_view = new QTableView(this); - layout->addWidget(m_view); + m_actRecord = new QAction(tr("Record"), this); + m_actRecord->setCheckable(true); + m_actRecord->setChecked(true); + m_actRecord->setToolTip(tr("Show the values of the current row next to the grid")); + m_toolbar->addAction(m_actRecord); + m_view = new QTableView(this); SetTableViewDefault(m_view); auto delegate = new PgLabItemDelegate(m_view); m_view->setItemDelegate(delegate); + + m_splitter = new QSplitter(Qt::Horizontal, this); + layout->addWidget(m_splitter); + m_splitter->addWidget(m_view); + + m_recordPanel = new RecordPanel(this); + m_recordPanel->setFirstDataColumn(CrudModel::PreColumnCount); + m_splitter->addWidget(m_recordPanel); + m_splitter->setStretchFactor(0, 1); + m_splitter->setStretchFactor(1, 0); + + connect(m_actRecord, &QAction::toggled, m_recordPanel, &QWidget::setVisible); } void DataGridWidget::setSourceModel(QAbstractItemModel *model) @@ -51,13 +70,18 @@ void DataGridWidget::setSourceModel(QAbstractItemModel *model) m_proxy->setSortRole(CustomSortRole); } disconnect(m_pendingChangesConnection); + disconnect(m_currentRowConnection); m_proxy->setSourceModel(model); m_view->setModel(m_proxy); m_view->setSortingEnabled(true); m_proxy->sort(0, Qt::AscendingOrder); + m_recordPanel->setModel(m_proxy); + m_currentRowConnection = connect(m_view->selectionModel(), &QItemSelectionModel::currentRowChanged, this, &DataGridWidget::onCurrentRowChanged); + auto crud = qobject_cast(model); - m_toolbar->setVisible(crud != nullptr); + m_actSave->setVisible(crud != nullptr); + m_actRevert->setVisible(crud != nullptr); if (crud) { m_pendingChangesConnection = connect(crud, &CrudModel::pendingChangesChanged, this, &DataGridWidget::onPendingChangesChanged); onPendingChangesChanged(crud->hasPendingChanges()); @@ -67,6 +91,11 @@ void DataGridWidget::setSourceModel(QAbstractItemModel *model) } } +void DataGridWidget::onCurrentRowChanged(const QModelIndex ¤t) +{ + m_recordPanel->setCurrentRow(current.row()); +} + void DataGridWidget::onPendingChangesChanged(bool hasChanges) { m_actSave->setEnabled(hasChanges); diff --git a/pglab/crud/DataGridWidget.h b/pglab/crud/DataGridWidget.h index d605053..bfcda78 100644 --- a/pglab/crud/DataGridWidget.h +++ b/pglab/crud/DataGridWidget.h @@ -5,9 +5,12 @@ class QAbstractItemModel; class QAction; +class QModelIndex; class QSortFilterProxyModel; +class QSplitter; class QTableView; class QToolBar; +class RecordPanel; /** Sortable and editable grid shared by the CRUD tab and the query result tab. */ @@ -26,6 +29,7 @@ private slots: void onPendingChangesChanged(bool hasChanges); void onSave(); void onRevert(); + void onCurrentRowChanged(const QModelIndex ¤t); private: /// Closes an open cell editor so its value is written to the model before saving or reverting @@ -33,10 +37,14 @@ private: QTableView *m_view = nullptr; QSortFilterProxyModel *m_proxy = nullptr; + QSplitter *m_splitter = nullptr; + RecordPanel *m_recordPanel = nullptr; QToolBar *m_toolbar = nullptr; QAction *m_actSave = nullptr; QAction *m_actRevert = nullptr; + QAction *m_actRecord = nullptr; QMetaObject::Connection m_pendingChangesConnection; + QMetaObject::Connection m_currentRowConnection; }; #endif // DATAGRIDWIDGET_H diff --git a/pglab/crud/RecordPanel.cpp b/pglab/crud/RecordPanel.cpp new file mode 100644 index 0000000..8183771 --- /dev/null +++ b/pglab/crud/RecordPanel.cpp @@ -0,0 +1,268 @@ +#include "RecordPanel.h" +#include "CustomDataRole.h" +#include "Pgsql_oids.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr int CollapsedTextLines = 2; +constexpr int ExpandedTextLines = 10; + +bool isLongTextType(Oid oid) +{ + switch (oid) { + case Pgsql::text_oid: + case Pgsql::varchar_oid: + case Pgsql::bpchar_oid: + case Pgsql::json_oid: + case Pgsql::jsonb_oid: + case Pgsql::xml_oid: + return true; + default: + return false; + } +} + +int textEditHeight(const QPlainTextEdit *edit, int lines) +{ + return edit->fontMetrics().lineSpacing() * lines + 2 * edit->frameWidth() + 6; +} + +QString editorText(const QWidget *editor) +{ + if (auto line = qobject_cast(editor)) + return line->text(); + if (auto plain = qobject_cast(editor)) + return plain->toPlainText(); + return {}; +} + +} // namespace + +RecordPanel::RecordPanel(QWidget *parent) + : QScrollArea(parent) +{ + setWidgetResizable(true); +} + +void RecordPanel::setModel(QAbstractItemModel *model) +{ + if (m_model) + m_model->disconnect(this); + + m_model = model; + if (m_model) { + connect(m_model, &QAbstractItemModel::dataChanged, this, &RecordPanel::refresh); + connect(m_model, &QAbstractItemModel::layoutChanged, this, &RecordPanel::refresh); + connect(m_model, &QAbstractItemModel::rowsInserted, this, &RecordPanel::refresh); + connect(m_model, &QAbstractItemModel::rowsRemoved, this, &RecordPanel::refresh); + connect(m_model, &QAbstractItemModel::modelReset, this, &RecordPanel::onModelReset); + } + onModelReset(); +} + +void RecordPanel::setFirstDataColumn(int column) +{ + m_firstDataColumn = column; + clearFields(); + refresh(); +} + +void RecordPanel::setCurrentRow(int row) +{ + if (m_model && row >= 0 && row < m_model->rowCount()) + m_rowIndex = QPersistentModelIndex(m_model->index(row, 0)); + else + m_rowIndex = QPersistentModelIndex(); + refresh(); +} + +void RecordPanel::onModelReset() +{ + m_rowIndex = QPersistentModelIndex(); + refresh(); +} + +void RecordPanel::refresh() +{ + if (!m_model || !m_rowIndex.isValid()) { + clearFields(); + return; + } + + int columns = m_model->columnCount() - m_firstDataColumn; + if (columns < 0) + columns = 0; + if (m_fields.size() != static_cast(columns)) + rebuild(); + + for (auto &field : m_fields) { + QModelIndex index = m_model->index(m_rowIndex.row(), field.column); + bool editable = m_model->flags(index).testFlag(Qt::ItemIsEditable); + QVariant value = index.data(Qt::EditRole); + QString text; + if (value.isValid() && !value.isNull()) { + text = value.toString(); + // An empty string is shown as '' so it can be told apart from NULL, the placeholder shows NULL + if (text.isEmpty()) + text = QStringLiteral("''"); + } + showValue(field, text, editable); + } +} + +void RecordPanel::clearFields() +{ + m_fields.clear(); + if (QWidget *old = takeWidget()) + old->deleteLater(); +} + +void RecordPanel::rebuild() +{ + clearFields(); + + auto content = new QWidget; + auto layout = new QFormLayout(content); + layout->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + + int row = m_rowIndex.row(); + for (int column = m_firstDataColumn; column < m_model->columnCount(); ++column) { + QString header = m_model->headerData(column, Qt::Horizontal, Qt::DisplayRole).toString(); + int newline = header.indexOf('\n'); + QString name = newline < 0 ? header : header.left(newline); + QString typeName = newline < 0 ? QString() : header.mid(newline + 1); + + auto caption = new QWidget(content); + auto captionLayout = new QVBoxLayout(caption); + captionLayout->setContentsMargins(0, 0, 0, 0); + auto nameLabel = new QLabel(name, caption); + QFont nameFont = nameLabel->font(); + nameFont.setBold(true); + nameLabel->setFont(nameFont); + auto typeLabel = new QLabel(typeName, caption); + captionLayout->addWidget(nameLabel); + captionLayout->addWidget(typeLabel); + + Oid oid = m_model->index(row, column).data(CustomDataTypeRole).toUInt(); + FieldRow field; + field.column = column; + QWidget *fieldWidget = nullptr; + + if (isLongTextType(oid)) { + auto container = new QWidget(content); + auto containerLayout = new QVBoxLayout(container); + containerLayout->setContentsMargins(0, 0, 0, 0); + + auto edit = new QPlainTextEdit(container); + edit->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + edit->setPlaceholderText(tr("null")); + edit->setFixedHeight(textEditHeight(edit, CollapsedTextLines)); + edit->installEventFilter(this); + + auto expand = new QToolButton(container); + expand->setCheckable(true); + expand->setArrowType(Qt::DownArrow); + expand->setToolTip(tr("Expand or collapse the value")); + connect(expand, &QToolButton::toggled, edit, [edit, expand](bool expanded) { + edit->setFixedHeight(textEditHeight(edit, expanded ? ExpandedTextLines : CollapsedTextLines)); + expand->setArrowType(expanded ? Qt::UpArrow : Qt::DownArrow); + }); + + auto buttonRow = new QHBoxLayout; + buttonRow->addStretch(); + buttonRow->addWidget(expand); + containerLayout->addWidget(edit); + containerLayout->addLayout(buttonRow); + + field.editor = edit; + field.expandButton = expand; + fieldWidget = container; + } + else { + auto edit = new QLineEdit(content); + edit->setPlaceholderText(tr("null")); + connect(edit, &QLineEdit::editingFinished, this, [this, edit]() { commitEditor(edit); }); + + field.editor = edit; + fieldWidget = edit; + } + + layout->addRow(caption, fieldWidget); + m_fields.push_back(field); + } + + setWidget(content); +} + +void RecordPanel::showValue(FieldRow &field, const QString &text, bool editable) +{ + QWidget *editor = field.editor; + if (auto line = qobject_cast(editor)) + line->setReadOnly(!editable); + else if (auto plain = qobject_cast(editor)) + plain->setReadOnly(!editable); + + // Do not replace the text while the user is typing in it + if (editor->hasFocus()) + return; + + if (auto line = qobject_cast(editor)) + line->setText(text); + else if (auto plain = qobject_cast(editor)) + plain->setPlainText(text); + field.original = text; +} + +void RecordPanel::commitEditor(QWidget *editor) +{ + if (m_updating || !m_model || !m_rowIndex.isValid()) + return; + + FieldRow *field = findField(editor); + if (!field) + return; + + QString text = editorText(editor); + if (text == field->original) + return; + + QModelIndex index = m_model->index(m_rowIndex.row(), field->column); + if (!m_model->flags(index).testFlag(Qt::ItemIsEditable)) + return; + + m_updating = true; + m_model->setData(index, text, Qt::EditRole); + m_updating = false; + + // The model may have rebuilt the fields while storing the value, so look the field up again + if (FieldRow *updated = findField(editor)) + updated->original = text; +} + +RecordPanel::FieldRow *RecordPanel::findField(QWidget *editor) +{ + for (auto &field : m_fields) { + if (field.editor == editor) + return &field; + } + return nullptr; +} + +bool RecordPanel::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::FocusOut) { + if (auto edit = qobject_cast(watched)) + commitEditor(edit); + } + return QScrollArea::eventFilter(watched, event); +} diff --git a/pglab/crud/RecordPanel.h b/pglab/crud/RecordPanel.h new file mode 100644 index 0000000..463038d --- /dev/null +++ b/pglab/crud/RecordPanel.h @@ -0,0 +1,55 @@ +#ifndef RECORDPANEL_H +#define RECORDPANEL_H + +#include +#include +#include + +class QAbstractItemModel; +class QEvent; +class QToolButton; + +/** Shows the values of one row of a model as a scrollable list, one line per column. + * Values are edited through the model so the pending change logic of the model is reused. + */ +class RecordPanel : public QScrollArea { + Q_OBJECT +public: + explicit RecordPanel(QWidget *parent = nullptr); + + /// Sets the model to show, the columns from firstDataColumn on are shown + void setModel(QAbstractItemModel *model); + /// Columns before this one are skipped, e.g. the row number column of the CRUD model + void setFirstDataColumn(int column); + /// Shows the values of the given row of the model, -1 shows nothing + void setCurrentRow(int row); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private slots: + void refresh(); + void onModelReset(); + +private: + struct FieldRow { + int column = -1; + QString original; ///< Text the editor shows, only a different text is written back + QWidget *editor = nullptr; + QToolButton *expandButton = nullptr; + }; + + void clearFields(); + void rebuild(); + void showValue(FieldRow &field, const QString &text, bool editable); + void commitEditor(QWidget *editor); + FieldRow *findField(QWidget *editor); + + QAbstractItemModel *m_model = nullptr; + int m_firstDataColumn = 0; + QPersistentModelIndex m_rowIndex; + std::vector m_fields; + bool m_updating = false; +}; + +#endif // RECORDPANEL_H diff --git a/pglab/tuplesresultwidget.ui b/pglab/tuplesresultwidget.ui index b13909e..9acdd3d 100644 --- a/pglab/tuplesresultwidget.ui +++ b/pglab/tuplesresultwidget.ui @@ -31,6 +31,12 @@ + + + 0 + 0 + + 4 diff --git a/releasenotes/notes/datagrid-record-panel-6d2b9e4a1c7f8350.yaml b/releasenotes/notes/datagrid-record-panel-6d2b9e4a1c7f8350.yaml new file mode 100644 index 0000000..da962b9 --- /dev/null +++ b/releasenotes/notes/datagrid-record-panel-6d2b9e4a1c7f8350.yaml @@ -0,0 +1,9 @@ +--- +features: + - | + Data grids in the table editor and in query results now have a record + panel next to the grid. It shows the column name, type and value of the + current row, long text, varchar, json, jsonb and xml values get an + expandable multi-line box. Values are editable exactly when the grid cell + is editable. The panel can be hidden with the :menuselection:`Record` + button. diff --git a/tests/integration/CMakeLists.txt b/tests/integration/CMakeLists.txt index eb33c27..ec8f25c 100644 --- a/tests/integration/CMakeLists.txt +++ b/tests/integration/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(IntegrationTests tst_CrudModelBehavior.cpp ${CMAKE_SOURCE_DIR}/pglab/crud/CrudModel.cpp ${CMAKE_SOURCE_DIR}/pglab/crud/DataGridWidget.cpp + ${CMAKE_SOURCE_DIR}/pglab/crud/RecordPanel.cpp ${CMAKE_SOURCE_DIR}/pglab/OpenDatabase.cpp ${CMAKE_SOURCE_DIR}/pglab/util/PgLabItemDelegate.cpp ${CMAKE_SOURCE_DIR}/pglab/util/Colors.cpp diff --git a/tests/pglabtests/CMakeLists.txt b/tests/pglabtests/CMakeLists.txt index d9f30de..01fe58a 100644 --- a/tests/pglabtests/CMakeLists.txt +++ b/tests/pglabtests/CMakeLists.txt @@ -18,6 +18,8 @@ add_executable(pglabtests tst_TypeMappings.cpp tst_Range.cpp ResultSourceMappingTest.cpp + tst_RecordPanel.cpp + ${CMAKE_SOURCE_DIR}/pglab/crud/RecordPanel.cpp ) target_link_libraries(pglabtests PRIVATE @@ -32,6 +34,8 @@ target_link_libraries(pglabtests PRIVATE target_include_directories(pglabtests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/pglab + ${CMAKE_SOURCE_DIR}/pglab/crud ) set_target_properties(pglabtests PROPERTIES diff --git a/tests/pglabtests/main.cpp b/tests/pglabtests/main.cpp index 291bdee..5755f0d 100644 --- a/tests/pglabtests/main.cpp +++ b/tests/pglabtests/main.cpp @@ -1,7 +1,13 @@ -#include +#include + +#include +#include int main(int argc, char *argv[]) { + if (!qEnvironmentVariableIsSet("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/tests/pglabtests/tst_RecordPanel.cpp b/tests/pglabtests/tst_RecordPanel.cpp new file mode 100644 index 0000000..44d7ffc --- /dev/null +++ b/tests/pglabtests/tst_RecordPanel.cpp @@ -0,0 +1,144 @@ +#include + +#include +#include +#include +#include +#include + +#include "CustomDataRole.h" +#include "Pgsql_oids.h" +#include "RecordPanel.h" + +namespace { + +/// Column 0 is the row number column, columns 1 to 3 are data columns +/// id (int4, editable), body (text, editable), ro (int4, read-only) +void fillModel(QStandardItemModel &model) +{ + model.setColumnCount(4); + model.setRowCount(1); + model.setHeaderData(0, Qt::Horizontal, QStringLiteral("#"), Qt::DisplayRole); + model.setHeaderData(1, Qt::Horizontal, QStringLiteral("id\nint4"), Qt::DisplayRole); + model.setHeaderData(2, Qt::Horizontal, QStringLiteral("body\ntext"), Qt::DisplayRole); + model.setHeaderData(3, Qt::Horizontal, QStringLiteral("ro\nint4"), Qt::DisplayRole); + model.setData(model.index(0, 1), Pgsql::int4_oid, CustomDataTypeRole); + model.setData(model.index(0, 2), Pgsql::text_oid, CustomDataTypeRole); + model.setData(model.index(0, 3), Pgsql::int4_oid, CustomDataTypeRole); + model.setData(model.index(0, 1), QStringLiteral("1")); + model.setData(model.index(0, 2), QStringLiteral("hello")); + model.setData(model.index(0, 3), QStringLiteral("7")); + model.itemFromIndex(model.index(0, 3))->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); +} + +} + +class RecordPanelTest : public ::testing::Test { +protected: + void SetUp() override + { + model.reset(new QStandardItemModel); + fillModel(*model); + panel.setModel(model.get()); + panel.setFirstDataColumn(1); + panel.setCurrentRow(0); + } + + std::unique_ptr model; + RecordPanel panel; +}; + +TEST_F(RecordPanelTest, OneFieldPerDataColumnSkippingRowNumber) +{ + ASSERT_NE(panel.widget(), nullptr); + EXPECT_EQ(panel.widget()->findChildren().size(), 6); + EXPECT_EQ(panel.widget()->findChildren().size(), 1); + EXPECT_EQ(panel.widget()->findChildren().size(), 2); +} + +TEST_F(RecordPanelTest, ShowsValuesOfCurrentRow) +{ + auto lines = panel.widget()->findChildren(); + auto texts = panel.widget()->findChildren(); + ASSERT_EQ(lines.size(), 2); + ASSERT_EQ(texts.size(), 1); + EXPECT_EQ(lines[0]->text(), QStringLiteral("1")); + EXPECT_EQ(texts[0]->toPlainText(), QStringLiteral("hello")); + EXPECT_EQ(lines[1]->text(), QStringLiteral("7")); +} + +TEST_F(RecordPanelTest, ReadOnlyWhenCellNotEditable) +{ + auto lines = panel.widget()->findChildren(); + ASSERT_EQ(lines.size(), 2); + EXPECT_FALSE(lines[0]->isReadOnly()); + EXPECT_TRUE(lines[1]->isReadOnly()); +} + +TEST_F(RecordPanelTest, NullShowsEmptyTextWithNullPlaceholder) +{ + model->setData(model->index(0, 1), QVariant(), Qt::EditRole); + panel.setCurrentRow(0); + + auto lines = panel.widget()->findChildren(); + ASSERT_EQ(lines.size(), 2); + EXPECT_TRUE(lines[0]->text().isEmpty()); + EXPECT_EQ(lines[0]->placeholderText(), QStringLiteral("null")); +} + +TEST_F(RecordPanelTest, EmptyStringShownAsQuotedEmpty) +{ + model->setData(model->index(0, 2), QString(""), Qt::EditRole); + panel.setCurrentRow(0); + + auto texts = panel.widget()->findChildren(); + ASSERT_EQ(texts.size(), 1); + EXPECT_EQ(texts[0]->toPlainText(), QStringLiteral("''")); +} + +TEST_F(RecordPanelTest, EditingFinishedWritesThroughModel) +{ + auto lines = panel.widget()->findChildren(); + ASSERT_EQ(lines.size(), 2); + lines[0]->setText(QStringLiteral("42")); + emit lines[0]->editingFinished(); + + EXPECT_EQ(model->index(0, 1).data(Qt::EditRole).toString(), QStringLiteral("42")); +} + +TEST_F(RecordPanelTest, DataChangedRefreshesEditor) +{ + model->setData(model->index(0, 1), QStringLiteral("5"), Qt::EditRole); + + auto lines = panel.widget()->findChildren(); + ASSERT_EQ(lines.size(), 2); + EXPECT_EQ(lines[0]->text(), QStringLiteral("5")); +} + +TEST_F(RecordPanelTest, NoCurrentRowShowsNothing) +{ + panel.setCurrentRow(-1); + EXPECT_EQ(panel.widget(), nullptr); +} + +TEST_F(RecordPanelTest, ModelResetClearsPanel) +{ + model->clear(); + EXPECT_EQ(panel.widget(), nullptr); +} + +TEST_F(RecordPanelTest, ExpandButtonGrowsTextBox) +{ + auto texts = panel.widget()->findChildren(); + auto buttons = panel.widget()->findChildren(); + ASSERT_EQ(texts.size(), 1); + ASSERT_EQ(buttons.size(), 1); + + int collapsed = texts[0]->maximumHeight(); + buttons[0]->setChecked(true); + int expanded = texts[0]->maximumHeight(); + EXPECT_GT(expanded, collapsed); + + buttons[0]->setChecked(false); + EXPECT_EQ(texts[0]->maximumHeight(), collapsed); +}