StatusQ: MovableModel proxy for setting custom order over source model

Proxy decorating source mode with additional method move(from, to, count)
similar to that available in ListModel. The custom order is stored within
a proxy, not altering the original model. May be useful whenever UI needs
to allow user to set custom order. Temporary state can be held in the
proxy, and send to the backend when changes are accepted.

Closes: #12686
This commit is contained in:
Michał Cieślak 2024-01-16 11:18:49 +01:00 committed by Michał
parent 22dcf8269f
commit e51667911d
7 changed files with 1164 additions and 1 deletions

View File

@ -0,0 +1,255 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQml 2.15
import StatusQ 0.1
import Models 1.0
import Storybook 1.0
Item {
id: root
ListModel {
id: simpleSourceModel
ListElement {
name: "entry 1"
}
ListElement {
name: "entry 2"
}
ListElement {
name: "entry 3"
}
ListElement {
name: "entry 4"
}
ListElement {
name: "entry 5"
}
ListElement {
name: "entry 6"
}
ListElement {
name: "entry 7"
}
ListElement {
name: "entry 8"
}
}
MovableModel {
id: movableModel
sourceModel: simpleSourceModel
}
RowLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 50
ColumnLayout {
Layout.preferredWidth: parent.width / 2
Layout.fillHeight: true
Label {
text: "SOURCE MODEL"
font.bold: true
font.pixelSize: 17
}
ListView {
id: sourceListView
spacing: 5
Layout.fillWidth: true
Layout.fillHeight: true
model: simpleSourceModel
ScrollBar.vertical: ScrollBar {}
delegate: RowLayout {
width: ListView.view.width
Label {
Layout.fillWidth: true
font.bold: true
text: model.name
}
Button {
text: "delete"
onClicked: simpleSourceModel.remove(model.index)
}
Button {
text: "alter"
onClicked: simpleSourceModel.setProperty(
index, "name", simpleSourceModel.get(index).name + "_")
}
Button {
text: "⬆️"
onClicked: {
if (index !== 0)
simpleSourceModel.move(index, index - 1, 1)
}
}
Button {
text: "⬇️"
onClicked: {
if (index !== simpleSourceModel.count - 1)
simpleSourceModel.move(index, index + 1, 1)
}
}
}
}
}
ColumnLayout {
Layout.preferredWidth: parent.width / 2
Layout.fillHeight: true
Label {
text: "DETACHED-ORDER MODEL"
font.bold: true
font.pixelSize: 17
}
ListView {
id: transformedListView
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 5
model: movableModel
ScrollBar.vertical: ScrollBar {}
delegate: MouseArea {
id: dragArea
property bool held: false
readonly property int idx: model.index
anchors {
left: parent ? parent.left : undefined
right: parent ? parent.right : undefined
}
height: content.implicitHeight
drag.target: held ? content : undefined
drag.axis: Drag.YAxis
onPressAndHold: held = true
onReleased: held = false
RowLayout {
id: content
anchors {
horizontalCenter: parent.horizontalCenter
verticalCenter: parent.verticalCenter
}
width: dragArea.width
Drag.active: dragArea.held
Drag.source: dragArea
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
states: State {
when: dragArea.held
ParentChange { target: content; parent: root }
AnchorChanges {
target: content
anchors {
horizontalCenter: undefined
verticalCenter: undefined
}
}
}
Label {
Layout.fillWidth: true
font.bold: true
text: model.name
}
Button {
text: "⬆️"
enabled: index > 0
onClicked: movableModel.move(index, index - 1)
}
Button {
text: "⬇️"
enabled: index < transformedListView.count - 1
onClicked: movableModel.move(index, index + 1)
}
}
DropArea {
anchors { fill: parent; margins: 10 }
onEntered: {
const from = drag.source.idx
const to = dragArea.idx
if (from === to)
return
movableModel.move(from, to)
}
}
}
}
}
}
RowLayout {
anchors.bottom: parent.bottom
anchors.margins: 10
Button {
text: "append to source model"
onClicked: simpleSourceModel.append({ name: "X" })
}
Button {
text: "detach order explicitely"
onClicked: {
movableModel.detach()
}
}
Label {
text: "Detached: " + movableModel.detached
}
}
}
// category: Models

View File

@ -101,6 +101,7 @@ add_library(StatusQ SHARED
include/StatusQ/formatteddoubleproperty.h
include/StatusQ/leftjoinmodel.h
include/StatusQ/modelutilsinternal.h
include/StatusQ/movablemodel.h
include/StatusQ/permissionutilsinternal.h
include/StatusQ/rolesrenamingmodel.h
include/StatusQ/rxvalidator.h
@ -120,6 +121,7 @@ add_library(StatusQ SHARED
src/formatteddoubleproperty.cpp
src/leftjoinmodel.cpp
src/modelutilsinternal.cpp
src/movablemodel.cpp
src/permissionutilsinternal.cpp
src/plugin.cpp
src/rolesrenamingmodel.cpp

View File

@ -0,0 +1,43 @@
#pragma once
#include <QAbstractListModel>
#include <QPointer>
#include <vector>
class MovableModel : public QAbstractListModel
{
Q_OBJECT
Q_PROPERTY(QAbstractItemModel* sourceModel READ sourceModel
WRITE setSourceModel NOTIFY sourceModelChanged)
Q_PROPERTY(bool detached READ detached NOTIFY detachedChanged)
public:
explicit MovableModel(QObject *parent = nullptr);
void setSourceModel(QAbstractItemModel *sourceModel);
QAbstractItemModel *sourceModel() const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role) const override;
QHash<int, QByteArray> roleNames() const override;
Q_INVOKABLE void detach();
Q_INVOKABLE void move(int from, int to, int count = 1);
Q_INVOKABLE QVector<int> order() const;
bool detached() const;
signals:
void sourceModelChanged();
void detachedChanged();
protected slots:
void resetInternalData();
private:
QPointer<QAbstractItemModel> m_sourceModel;
bool m_detached = false;
std::vector<QPersistentModelIndex> m_indexes;
};

View File

@ -0,0 +1,280 @@
#include "StatusQ/movablemodel.h"
#include <algorithm>
#include <QDebug>
MovableModel::MovableModel(QObject* parent)
: QAbstractListModel{parent}
{
}
void MovableModel::setSourceModel(QAbstractItemModel* sourceModel)
{
if (m_sourceModel == sourceModel)
return;
beginResetModel();
if (m_sourceModel != nullptr)
disconnect(m_sourceModel, nullptr, this, nullptr);
m_sourceModel = sourceModel;
if (sourceModel != nullptr) {
connect(sourceModel, &QAbstractItemModel::rowsAboutToBeInserted, this,
&MovableModel::beginInsertRows);
connect(sourceModel, &QAbstractItemModel::rowsInserted, this,
&MovableModel::endInsertRows);
connect(sourceModel, &QAbstractItemModel::rowsAboutToBeRemoved, this,
&MovableModel::beginRemoveRows);
connect(sourceModel, &QAbstractItemModel::rowsRemoved, this,
&MovableModel::endRemoveRows);
connect(sourceModel, &QAbstractItemModel::rowsAboutToBeMoved, this,
&MovableModel::beginMoveRows);
connect(sourceModel, &QAbstractItemModel::rowsMoved, this,
&MovableModel::endMoveRows);
connect(sourceModel, &QAbstractItemModel::dataChanged, this,
&MovableModel::dataChanged);
connect(sourceModel, &QAbstractItemModel::layoutAboutToBeChanged, this,
&MovableModel::layoutAboutToBeChanged);
connect(sourceModel, &QAbstractItemModel::layoutChanged, this,
&MovableModel::layoutChanged);
connect(sourceModel, &QAbstractItemModel::modelAboutToBeReset, this,
&MovableModel::beginResetModel);
connect(sourceModel, &QAbstractItemModel::modelReset, this,
&MovableModel::endResetModel);
}
emit sourceModelChanged();
endResetModel();
}
QAbstractItemModel* MovableModel::sourceModel() const
{
return m_sourceModel;
}
int MovableModel::rowCount(const QModelIndex &parent) const
{
if (m_detached)
return m_indexes.size();
if (m_sourceModel == nullptr)
return 0;
return m_sourceModel->rowCount();
}
QVariant MovableModel::data(const QModelIndex &index, int role) const
{
if (!checkIndex(index, CheckIndexOption::IndexIsValid))
return {};
if (m_detached)
return m_indexes.at(index.row()).data(role);
if (m_sourceModel == nullptr)
return {};
return m_sourceModel->data(index, role);
}
QHash<int, QByteArray> MovableModel::roleNames() const
{
if (m_sourceModel == nullptr)
return {};
return m_sourceModel->roleNames();
}
void MovableModel::detach()
{
if (m_detached || m_sourceModel == nullptr)
return;
disconnect(m_sourceModel, &QAbstractItemModel::rowsAboutToBeInserted, this,
&MovableModel::beginInsertRows);
disconnect(m_sourceModel, &QAbstractItemModel::rowsInserted, this,
&MovableModel::endInsertRows);
disconnect(m_sourceModel, &QAbstractItemModel::rowsAboutToBeRemoved, this,
&MovableModel::beginRemoveRows);
disconnect(m_sourceModel, &QAbstractItemModel::rowsRemoved, this,
&MovableModel::endRemoveRows);
disconnect(m_sourceModel, &QAbstractItemModel::rowsAboutToBeMoved, this,
&MovableModel::beginMoveRows);
disconnect(m_sourceModel, &QAbstractItemModel::rowsMoved, this,
&MovableModel::endMoveRows);
disconnect(m_sourceModel, &QAbstractItemModel::dataChanged, this,
&MovableModel::dataChanged);
disconnect(m_sourceModel, &QAbstractItemModel::layoutAboutToBeChanged, this,
&MovableModel::layoutAboutToBeChanged);
disconnect(m_sourceModel, &QAbstractItemModel::layoutChanged, this,
&MovableModel::layoutChanged);
connect(m_sourceModel, &QAbstractItemModel::dataChanged, this,
[this](const QModelIndex& topLeft, const QModelIndex& bottomRight,
const QVector<int>& roles) {
emit dataChanged(index(0), index(rowCount() - 1), roles);
});
connect(m_sourceModel, &QAbstractItemModel::rowsInserted, this,
[this](const QModelIndex &parent, int first, int last) {
beginInsertRows({}, first, last);
int oldCount = m_indexes.size();
int insertCount = last - first + 1;
m_indexes.reserve(m_indexes.size() + insertCount);
for (auto i = first; i <= last; i++)
m_indexes.emplace_back(m_sourceModel->index(i, 0));
std::rotate(m_indexes.begin() + first, m_indexes.begin() + oldCount,
m_indexes.end());
endInsertRows();
});
connect(m_sourceModel, &QAbstractItemModel::rowsAboutToBeRemoved, this,
[this] (const QModelIndex &parent, int first, int last) {
std::vector<int> indicesToRemove;
indicesToRemove.reserve(last - first + 1);
for (auto i = 0; i < m_indexes.size(); i++) {
const QPersistentModelIndex& idx = m_indexes.at(i);
if (idx.row() >= first && idx.row() <= last)
indicesToRemove.push_back(i);
}
std::vector<std::pair<int, int>> sequences;
auto sequenceBegin = indicesToRemove.front();
auto sequenceEnd = sequenceBegin;
for (auto i = 1; i < indicesToRemove.size(); i++) {
auto idxToRemove = indicesToRemove[i];
auto idxDiff = idxToRemove - sequenceEnd;
if (idxDiff == 1)
sequenceEnd = idxToRemove;
if (idxDiff != 1 || i == indicesToRemove.size() - 1) {
sequences.emplace_back(sequenceBegin, sequenceEnd);
sequenceBegin = idxToRemove;
sequenceEnd = idxToRemove;
}
}
if (sequences.empty())
sequences.emplace_back(sequenceBegin, sequenceEnd);
auto end = sequences.crend();
for (auto it = sequences.crbegin(); it != end; it++) {
beginRemoveRows({}, it->first, it->second);
m_indexes.erase(m_indexes.begin() + it->first,
m_indexes.begin() + it->second + 1);
endRemoveRows();
}
});
auto count = m_sourceModel->rowCount();
m_indexes.clear();
m_indexes.reserve(count);
for (auto i = 0; i < count; i++)
m_indexes.emplace_back(m_sourceModel->index(i, 0));
m_detached = true;
emit detachedChanged();
}
void MovableModel::move(int from, int to, int count)
{
const int rows = rowCount();
if (from < 0 || to < 0 || count <= 0
|| from + count > rows || to + count > rows) {
qWarning() << "MovableModel: move: out of range";
return;
}
if (from == to)
return;
const int sourceFirst = from;
const int sourceLast = from + count - 1;
const int destinationRow = to < from ? to : to + count;
if (!m_detached)
detach();
beginMoveRows({}, sourceFirst, sourceLast, {}, destinationRow);
std::vector<QPersistentModelIndex> movedIndexes;
movedIndexes.reserve(count);
std::move(m_indexes.begin() + sourceFirst,
m_indexes.begin() + sourceLast + 1,
std::back_insert_iterator(movedIndexes));
m_indexes.erase(m_indexes.begin() + sourceFirst,
m_indexes.begin() + sourceLast + 1);
m_indexes.insert(m_indexes.begin() + to,
std::move_iterator(movedIndexes.begin()),
std::move_iterator(movedIndexes.end()));
endMoveRows();
}
QVector<int> MovableModel::order() const
{
QVector<int> order(rowCount());
if (m_detached)
std::transform(m_indexes.begin(), m_indexes.end(), order.begin(),
[](auto& idx) { return idx.row(); });
else
std::iota(order.begin(), order.end(), 0);
return order;
}
bool MovableModel::detached() const
{
return m_detached;
}
void MovableModel::resetInternalData()
{
QAbstractListModel::resetInternalData();
m_indexes.clear();
if (m_detached) {
m_detached = false;
emit detachedChanged();
}
}

View File

@ -10,6 +10,7 @@
#include "StatusQ/fastexpressionsorter.h"
#include "StatusQ/leftjoinmodel.h"
#include "StatusQ/modelutilsinternal.h"
#include "StatusQ/movablemodel.h"
#include "StatusQ/permissionutilsinternal.h"
#include "StatusQ/rolesrenamingmodel.h"
#include "StatusQ/rxvalidator.h"
@ -42,6 +43,7 @@ public:
qmlRegisterType<SourceModel>("StatusQ", 0, 1, "SourceModel");
qmlRegisterType<ConcatModel>("StatusQ", 0, 1, "ConcatModel");
qmlRegisterType<MovableModel>("StatusQ", 0, 1, "MovableModel");
qmlRegisterType<FastExpressionFilter>("StatusQ", 0, 1, "FastExpressionFilter");
qmlRegisterType<FastExpressionRole>("StatusQ", 0, 1, "FastExpressionRole");

View File

@ -85,5 +85,9 @@ target_link_libraries(ConcatModelTest PRIVATE StatusQ StatusQTestLib)
add_test(NAME ConcatModelTest COMMAND ConcatModelTest)
add_executable(WritableProxyModelTest tst_WritableProxyModel.cpp)
target_link_libraries(WritableProxyModelTest PRIVATE Qt5::Qml Qt5::Test StatusQ)
target_link_libraries(WritableProxyModelTest PRIVATE StatusQ StatusQTestLib)
add_test(NAME WritableProxyModelTest COMMAND WritableProxyModelTest)
add_executable(MovableModelTest tst_MovableModel.cpp)
target_link_libraries(MovableModelTest PRIVATE StatusQ StatusQTestLib)
add_test(NAME MovableModelTest COMMAND MovableModelTest)

View File

@ -0,0 +1,577 @@
#include <QSignalSpy>
#include <QTest>
#include <QJsonArray>
#include <QJsonObject>
#include <QQmlEngine>
#include <QSortFilterProxyModel>
#include <StatusQ/movablemodel.h>
#include <TestHelpers/listmodelwrapper.h>
#include <TestHelpers/modelsignalsspy.h>
#include <TestHelpers/modeltestutils.h>
#include <TestHelpers/snapshotmodel.h>
class TestMovableModel : public QObject
{
Q_OBJECT
int roleForName(const QHash<int, QByteArray>& roles,
const QByteArray& name) const
{
auto keys = roles.keys(name);
if (keys.empty())
return -1;
return keys.first();
}
static constexpr QModelIndex InvalidIdx{};
private slots:
void initializationTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
QCOMPARE(model.detached(), false);
QVERIFY(isSame(&model, sourceModel));
}
void detachTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
QSignalSpy detachChangedSpy(&model, &MovableModel::detachedChanged);
model.detach();
QCOMPARE(detachChangedSpy.count(), 1);
QCOMPARE(model.detached(), true);
QVERIFY(isSame(&model, sourceModel));
model.setSourceModel(nullptr);
QCOMPARE(detachChangedSpy.count(), 2);
QCOMPARE(model.detached(), false);
QCOMPARE(model.rowCount(), 0);
}
void moveDownTest()
{
QQmlEngine engine;
auto source = R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])";
ListModelWrapper sourceModel(engine, source);
ListModelWrapper sourceModelCopy(engine, source);
MovableModel model;
model.setSourceModel(sourceModel);
model.detach();
ModelSignalsSpy signalsSpy(&model);
ModelSignalsSpy referenceSignalsSpy(sourceModelCopy);
model.move(0, 2, 2);
sourceModelCopy.move(0, 2, 2);
QCOMPARE(signalsSpy.count(), 2);
QCOMPARE(referenceSignalsSpy.count(), 2);
QCOMPARE(signalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsMovedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsAboutToBeMovedSpy.first(),
referenceSignalsSpy.rowsAboutToBeMovedSpy.first());
QCOMPARE(signalsSpy.rowsMovedSpy.first(),
referenceSignalsSpy.rowsMovedSpy.first());
ListModelWrapper expected(engine, R"([
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
QVERIFY(isSame(sourceModelCopy, expected));
QVERIFY(isSame(&model, expected));
QVERIFY(isSame(&model, sourceModelCopy));
}
void moveUpTest()
{
QQmlEngine engine;
auto source = R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])";
ListModelWrapper sourceModel(engine, source);
ListModelWrapper sourceModelCopy(engine, source);
MovableModel model;
model.setSourceModel(sourceModel);
model.detach();
ModelSignalsSpy signalsSpy(&model);
ModelSignalsSpy referenceSignalsSpy(sourceModelCopy);
model.move(3, 1, 2);
sourceModelCopy.move(3, 1, 2);
QCOMPARE(signalsSpy.count(), 2);
QCOMPARE(referenceSignalsSpy.count(), 2);
QCOMPARE(signalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsMovedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsAboutToBeMovedSpy.first(),
referenceSignalsSpy.rowsAboutToBeMovedSpy.first());
QCOMPARE(signalsSpy.rowsMovedSpy.first(),
referenceSignalsSpy.rowsMovedSpy.first());
ListModelWrapper expected(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c3" }
])");
QVERIFY(isSame(sourceModelCopy, expected));
QVERIFY(isSame(&model, expected));
QVERIFY(isSame(&model, sourceModelCopy));
}
void moveToEndTest()
{
QQmlEngine engine;
auto source = R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])";
ListModelWrapper sourceModel(engine, source);
ListModelWrapper sourceModelCopy(engine, source);
MovableModel model;
model.setSourceModel(sourceModel);
model.detach();
ModelSignalsSpy signalsSpy(&model);
ModelSignalsSpy referenceSignalsSpy(sourceModelCopy);
model.move(1, 4, 2);
sourceModelCopy.move(1, 4, 2);
QCOMPARE(signalsSpy.count(), 2);
QCOMPARE(referenceSignalsSpy.count(), 2);
QCOMPARE(signalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsMovedSpy.count(), 1);
ListModelWrapper expected(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" }
])");
QVERIFY(isSame(sourceModelCopy, expected));
QVERIFY(isSame(&model, expected));
QVERIFY(isSame(&model, sourceModelCopy));
}
void moveToBeginningTest()
{
QQmlEngine engine;
auto source = R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])";
ListModelWrapper sourceModel(engine, source);
ListModelWrapper sourceModelCopy(engine, source);
MovableModel model;
model.setSourceModel(sourceModel);
model.detach();
ModelSignalsSpy signalsSpy(&model);
ModelSignalsSpy referenceSignalsSpy(sourceModelCopy);
model.move(3, 0, 3);
sourceModelCopy.move(3, 0, 3);
QCOMPARE(signalsSpy.count(), 2);
QCOMPARE(referenceSignalsSpy.count(), 2);
QCOMPARE(signalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsAboutToBeMovedSpy.count(), 1);
QCOMPARE(referenceSignalsSpy.rowsMovedSpy.count(), 1);
ListModelWrapper expected(engine, R"([
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" },
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" }
])");
QVERIFY(isSame(sourceModelCopy, expected));
QVERIFY(isSame(&model, expected));
QVERIFY(isSame(&model, sourceModelCopy));
}
void sortingTest()
{
QQmlEngine engine;
auto source = R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])";
ListModelWrapper sourceModel(engine, source);
ListModelWrapper sourceModelCopy(engine, source);
QSortFilterProxyModel sfpm;
sfpm.setSourceModel(sourceModel);
MovableModel model;
model.setSourceModel(&sfpm);
model.detach();
model.move(2, 1);
sourceModelCopy.move(2, 1);
ModelSignalsSpy signalsSpy(&model);
sfpm.setSortRole(1);
sfpm.sort(0, Qt::DescendingOrder);
ListModelWrapper expectedSorted(engine, R"([
{ "name": "C", "subname": "c3" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c1" },
{ "name": "B", "subname": "b1" },
{ "name": "A", "subname": "a2" },
{ "name": "A", "subname": "a1" }
])");
QCOMPARE(signalsSpy.count(), 0);
QCOMPARE(sfpm.roleNames().value(1), "subname");
QVERIFY(isSame(&sfpm, expectedSorted));
QVERIFY(isSame(&model, sourceModelCopy));
}
void insertionTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
model.detach();
model.move(4, 1);
SnapshotModel snapshot(model);
QObject context;
connect(&model, &QAbstractItemModel::rowsAboutToBeInserted, &context,
[m = &model, s = &snapshot] {
QVERIFY(isSame(m, s));
});
ModelSignalsSpy signalsSpy(&model);
sourceModel.insert(3, QJsonArray {
QJsonObject {{ "name", "D"}, { "subname", "d1" }},
QJsonObject {{ "name", "D"}, { "subname", "d2" }}
});
ListModelWrapper expected(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "C", "subname": "c2" },
{ "name": "A", "subname": "a2" },
{ "name": "D", "subname": "d1" },
{ "name": "D", "subname": "d2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c3" }
])");
QCOMPARE(signalsSpy.count(), 2);
QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsInsertedSpy.count(), 1);
QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.first().at(0), QModelIndex{});
QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.first().at(1), 3);
QCOMPARE(signalsSpy.rowsAboutToBeInsertedSpy.first().at(2), 4);
QCOMPARE(signalsSpy.rowsInsertedSpy.first().at(0), QModelIndex{});
QCOMPARE(signalsSpy.rowsInsertedSpy.first().at(1), 3);
QCOMPARE(signalsSpy.rowsInsertedSpy.first().at(2), 4);
QVERIFY(isSame(&model, expected));
}
void removalTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
model.move(3, 0, 3);
ListModelWrapper expectedIntermediate(engine, R"([
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" },
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" }
])");
QVERIFY(isSame(&model, expectedIntermediate));
ModelSignalsSpy signalsSpy(&model);
sourceModel.remove(1, 4);
ListModelWrapper expected(engine, R"([
{ "name": "C", "subname": "c3" },
{ "name": "A", "subname": "a1" }
])");
QVERIFY(isSame(&model, expected));
QCOMPARE(signalsSpy.count(), 4);
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.count(), 2);
QCOMPARE(signalsSpy.rowsRemovedSpy.count(), 2);
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(0), QModelIndex{});
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(1), 4);
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(0).at(2), 5);
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(1).at(0), QModelIndex{});
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(1).at(1), 0);
QCOMPARE(signalsSpy.rowsAboutToBeRemovedSpy.at(1).at(2), 1);
QCOMPARE(signalsSpy.rowsRemovedSpy.at(0).at(0), QModelIndex{});
QCOMPARE(signalsSpy.rowsRemovedSpy.at(0).at(1), 4);
QCOMPARE(signalsSpy.rowsRemovedSpy.at(0).at(2), 5);
QCOMPARE(signalsSpy.rowsRemovedSpy.at(1).at(0), QModelIndex{});
QCOMPARE(signalsSpy.rowsRemovedSpy.at(1).at(1), 0);
QCOMPARE(signalsSpy.rowsRemovedSpy.at(1).at(2), 1);
}
void dataChangeTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
model.detach();
ModelSignalsSpy signalsSpy(&model);
sourceModel.setProperty(1, "subname", "a2_");
ListModelWrapper expected(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2_" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
auto subnameRole = roleForName(model.roleNames(), "subname");
QCOMPARE(signalsSpy.count(), 1);
QCOMPARE(signalsSpy.dataChangedSpy.count(), 1);
QCOMPARE(signalsSpy.dataChangedSpy.first().at(0), model.index(0));
QCOMPARE(signalsSpy.dataChangedSpy.first().at(1), model.index(model.rowCount() - 1));
QCOMPARE(signalsSpy.dataChangedSpy.first().at(2).value<QVector<int>>(),
{subnameRole});
QVERIFY(isSame(&model, expected));
}
void orderTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
QVector<int> expectedOrder = {0, 1, 2, 3, 4, 5};
QCOMPARE(model.order(), expectedOrder);
sourceModel.move(0, 1);
QCOMPARE(model.order(), expectedOrder);
sourceModel.move(0, 1); // restore original source order
model.move(0, 1);
expectedOrder = {1, 0, 2, 3, 4, 5};
QCOMPARE(model.order(), expectedOrder);
sourceModel.move(0, 1);
expectedOrder = {0, 1, 2, 3, 4, 5};
QCOMPARE(model.order(), expectedOrder);
sourceModel.move(0, 1); // restore original source order
model.move(0, 1);
expectedOrder = {0, 1, 2, 3, 4, 5};
QCOMPARE(model.order(), expectedOrder);
model.move(1, 3, 2);
expectedOrder = {0, 3, 4, 1, 2, 5};
QCOMPARE(model.order(), expectedOrder);
sourceModel.remove(4);
expectedOrder = {0, 3, 1, 2, 4};
QCOMPARE(model.order(), expectedOrder);
}
void invalidMoveTest()
{
QQmlEngine engine;
ListModelWrapper sourceModel(engine, R"([
{ "name": "A", "subname": "a1" },
{ "name": "A", "subname": "a2" },
{ "name": "B", "subname": "b1" },
{ "name": "C", "subname": "c1" },
{ "name": "C", "subname": "c2" },
{ "name": "C", "subname": "c3" }
])");
MovableModel model;
model.setSourceModel(sourceModel);
{
QTest::ignoreMessage(QtWarningMsg,
"MovableModel: move: out of range");
model.move(2, -1);
}
{
QTest::ignoreMessage(QtWarningMsg,
"MovableModel: move: out of range");
model.move(-2, 1);
}
{
QTest::ignoreMessage(QtWarningMsg,
"MovableModel: move: out of range");
model.move(5, 0, 2);
}
{
QTest::ignoreMessage(QtWarningMsg,
"MovableModel: move: out of range");
model.move(0, 5, 2);
}
// QTest::failOnWarning(QRegularExpression(".?")); // Qt 6.3
sourceModel.move(0, 0, 2);
}
};
QTEST_MAIN(TestMovableModel)
#include "tst_MovableModel.moc"