diff --git a/storybook/pages/RolesRenamingModelPage.qml b/storybook/pages/RolesRenamingModelPage.qml new file mode 100644 index 0000000000..4100524a9f --- /dev/null +++ b/storybook/pages/RolesRenamingModelPage.qml @@ -0,0 +1,156 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 + +Item { + id: root + + RolesRenamingModel { + id: renamedModel + + sourceModel: sourceModel + + mapping: [ + RoleRename { + from: "tokenId" + to: "id" + }, + RoleRename { + from: "title" + to: "name" + } + ] + } + + ListModel { + id: sourceModel + + ListElement { + tokenId: "1" + title: "Token 1" + communityId: "1" + } + ListElement { + tokenId: "2" + title: "Token 2" + communityId: "1" + } + ListElement { + tokenId: "3" + title: "Token 3" + communityId: "2" + } + ListElement { + tokenId: "4" + title: "Token 4" + communityId: "3" + } + ListElement { + tokenId: "5" + title: "Token 5" + communityId: "" + } + ListElement { + tokenId: "6" + title: "Token 6" + communityId: "1" + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 40 + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + clip: true + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + + border.color: "gray" + + ListView { + anchors.fill: parent + + model: sourceModel + + header: Label { + height: implicitHeight * 2 + text: `Left model (${sourceModel.count})` + + font.bold: true + + verticalAlignment: Text.AlignVCenter + } + + ScrollBar.vertical: ScrollBar {} + + delegate: Label { + width: ListView.view.width + + text: `token id: ${model.tokenId}, ${model.title}, community id: ${model.communityId || "-"}` + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + + border.color: "gray" + + ListView { + id: renamedListView + + anchors.fill: parent + + model: renamedModel + + header: Label { + height: implicitHeight * 2 + text: `Renamed model (${renamedListView.count})` + + font.bold: true + + verticalAlignment: Text.AlignVCenter + } + + ScrollBar.vertical: ScrollBar {} + + delegate: Label { + width: ListView.view.width + + text: `id: ${model.id}, ${model.name}, community id: ${model.communityId || "-"}` + } + } + } + } + + RowLayout { + Layout.fillWidth: true + + Button { + text: "shuffle" + + onClicked: { + const count = sourceModel.count + const iterations = count / 2 + + for (let i = 0; i < iterations; i++) { + sourceModel.move(Math.floor(Math.random() * (count - 1)), + Math.floor(Math.random() * (count - 1)), + Math.floor(Math.random() * 2) + 1) + } + } + } + } + } +} + +// category: Models diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index c7194c26cf..4964f77472 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -13,8 +13,8 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Although SHARED libraries set this to ON by default, -# all static libraries, that are built into this SHARED, -# (which is qzxing in our case) should also be build with -fPIC. +# all static libraries, that are built into this SHARED, +# (which is qzxing in our case) should also be build with -fPIC. # This fixes it. set(CMAKE_POSITION_INDEPENDENT_CODE ON) @@ -91,6 +91,7 @@ add_library(StatusQ SHARED include/StatusQ/QClipboardProxy.h include/StatusQ/modelutilsinternal.h include/StatusQ/permissionutilsinternal.h + include/StatusQ/rolesrenamingmodel.h include/StatusQ/rxvalidator.h include/StatusQ/statussyntaxhighlighter.h include/StatusQ/statuswindow.h @@ -99,13 +100,14 @@ add_library(StatusQ SHARED src/modelutilsinternal.cpp src/permissionutilsinternal.cpp src/plugin.cpp + src/rolesrenamingmodel.cpp src/rxvalidator.cpp src/statussyntaxhighlighter.cpp src/statuswindow.cpp src/stringutilsinternal.cpp ) -set_target_properties(StatusQ PROPERTIES +set_target_properties(StatusQ PROPERTIES ADDITIONAL_CLEAN_FILES bin/StatusQ/qmldir RUNTIME_OUTPUT_DIRECTORY ${STATUSQ_MODULE_PATH} RUNTIME_OUTPUT_DIRECTORY_DEBUG ${STATUSQ_MODULE_PATH} @@ -144,7 +146,7 @@ target_link_libraries(StatusQ PRIVATE qzxing ) -target_include_directories(StatusQ PRIVATE include) +target_include_directories(StatusQ PUBLIC include) install(TARGETS StatusQ RUNTIME DESTINATION StatusQ diff --git a/ui/StatusQ/include/StatusQ/rolesrenamingmodel.h b/ui/StatusQ/include/StatusQ/rolesrenamingmodel.h new file mode 100644 index 0000000000..8854d0824b --- /dev/null +++ b/ui/StatusQ/include/StatusQ/rolesrenamingmodel.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +class RoleRename : public QObject { + Q_OBJECT + + Q_PROPERTY(QString from READ from WRITE setFrom NOTIFY fromChanged) + Q_PROPERTY(QString to READ to WRITE setTo NOTIFY toChanged) + +public: + explicit RoleRename(QObject* parent = nullptr); + + void setFrom(const QString& from); + const QString& from() const; + + void setTo(const QString& to); + const QString& to() const; + +signals: + void fromChanged(); + void toChanged(); + +private: + QString m_from; + QString m_to; +}; + +class RolesRenamingModel : public QIdentityProxyModel +{ + Q_OBJECT + Q_PROPERTY(QQmlListProperty mapping READ mapping CONSTANT) + +public: + explicit RolesRenamingModel(QObject* parent = nullptr); + + QQmlListProperty mapping(); + QHash roleNames() const override; + +private: + mutable bool m_rolesFetched = false; + QList m_mapping; +}; diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index bd62201ac9..3c9ccb06b0 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -6,6 +6,7 @@ #include "StatusQ/QClipboardProxy.h" #include "StatusQ/modelutilsinternal.h" #include "StatusQ/permissionutilsinternal.h" +#include "StatusQ/rolesrenamingmodel.h" #include "StatusQ/rxvalidator.h" #include "StatusQ/statussyntaxhighlighter.h" #include "StatusQ/statuswindow.h" @@ -24,6 +25,9 @@ public: qmlRegisterType("StatusQ", 0, 1, "StatusSyntaxHighlighter"); qmlRegisterType("StatusQ", 0, 1, "RXValidator"); + qmlRegisterType("StatusQ", 0, 1, "RolesRenamingModel"); + qmlRegisterType("StatusQ", 0, 1, "RoleRename"); + qmlRegisterSingletonType("StatusQ", 0, 1, "QClipboardProxy", &QClipboardProxy::qmlInstance); qmlRegisterSingletonType( diff --git a/ui/StatusQ/src/rolesrenamingmodel.cpp b/ui/StatusQ/src/rolesrenamingmodel.cpp new file mode 100644 index 0000000000..12235492a5 --- /dev/null +++ b/ui/StatusQ/src/rolesrenamingmodel.cpp @@ -0,0 +1,117 @@ +#include "StatusQ/rolesrenamingmodel.h" + +#include + +RoleRename::RoleRename(QObject* parent) + : QObject{parent} +{ +} + +void RoleRename::setFrom(const QString& from) +{ + if (m_from == from) + return; + + if (!m_from.isEmpty()) { + qWarning() << "RoleRename: property \"from\" is inteded to be initialized once and not changed!"; + return; + } + + m_from = from; + emit fromChanged(); +} + +const QString& RoleRename::from() const +{ + return m_from; +} + +void RoleRename::setTo(const QString& to) +{ + if (m_to == to) + return; + + if (!m_to.isEmpty()) { + qWarning() << "RoleRename: property \"to\" is inteded to be initialized once and not changed!"; + return; + } + + m_to = to; + emit toChanged(); +} + +const QString& RoleRename::to() const +{ + return m_to; +} + +RolesRenamingModel::RolesRenamingModel(QObject* parent) + : QIdentityProxyModel{parent} +{ +} + +QQmlListProperty RolesRenamingModel::mapping() +{ + QQmlListProperty list(this, &m_mapping); + + list.replace = nullptr; + list.clear = nullptr; + list.removeLast = nullptr; + + list.append = [](auto listProperty, auto element) { + RolesRenamingModel* model = qobject_cast( + listProperty->object); + + if (model->m_rolesFetched) { + qWarning() << "RolesRenamingModel: role names mapping cannot be modified after fetching role names!"; + return; + } + + model->m_mapping.append(element); + }; + + return list; +} + +QHash RolesRenamingModel::roleNames() const +{ + QHash roles = sourceModel() + ? sourceModel()->roleNames() + : QHash{}; + + if (roles.empty()) + return roles; + + QHash renameMap; + + for (const auto rename : m_mapping) + renameMap.insert(rename->from(), rename); + + QHash remapped; + remapped.reserve(roles.size()); + + QSet roleNamesSet; + roleNamesSet.reserve(roles.size()); + + for (auto i = roles.cbegin(), end = roles.cend(); i != end; ++i) { + RoleRename* rename = renameMap.take(i.value()); + QByteArray roleName = rename ? rename->to().toUtf8() : i.value(); + + remapped.insert(i.key(), roleName); + roleNamesSet.insert(roleName); + } + + if (roles.size() != roleNamesSet.size()) { + qWarning() << "RolesRenamingModel: model cannot contain duplicated role names!"; + return {}; + } + + if (renameMap.size()) { + qWarning().nospace() + << "RolesRenamingModel: specified source roles not found: " + << renameMap.keys() << "!"; + } + + m_rolesFetched = true; + return remapped; +} diff --git a/ui/StatusQ/tests/CMakeLists.txt b/ui/StatusQ/tests/CMakeLists.txt index 2455a0bf74..f0293a5910 100644 --- a/ui/StatusQ/tests/CMakeLists.txt +++ b/ui/StatusQ/tests/CMakeLists.txt @@ -16,7 +16,9 @@ add_definitions(-DQUICK_TEST_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}") add_executable(${PROJECT_NAME} main.cpp) -add_test(NAME ${PROJECT_NAME} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} COMMAND ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME} -input "${CMAKE_CURRENT_SOURCE_DIR}") +add_test(NAME ${PROJECT_NAME} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + COMMAND ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME} -input "${CMAKE_CURRENT_SOURCE_DIR}") + add_custom_target("Run_${PROJECT_NAME}" COMMAND ${CMAKE_CTEST_COMMAND} --test-dir "${CMAKE_CURRENT_BINARY_DIR}") add_dependencies("Run_${PROJECT_NAME}" ${PROJECT_NAME}) @@ -38,3 +40,7 @@ target_compile_definitions(${PROJECT_NAME} PRIVATE STATUSQ_MODULE_PATH="${STATUSQ_MODULE_PATH}" STATUSQ_MODULE_IMPORT_PATH="${STATUSQ_MODULE_IMPORT_PATH}" ) + +add_executable(RolesRenamingModelTest tst_RolesRenamingModel.cpp) +target_link_libraries(RolesRenamingModelTest PRIVATE Qt5::Qml Qt5::Test StatusQ) +add_test(RolesRenamingModelTest COMMAND RolesRenamingModelTest) diff --git a/ui/StatusQ/tests/tst_RolesRenamingModel.cpp b/ui/StatusQ/tests/tst_RolesRenamingModel.cpp new file mode 100644 index 0000000000..31fff49f86 --- /dev/null +++ b/ui/StatusQ/tests/tst_RolesRenamingModel.cpp @@ -0,0 +1,212 @@ +#include +#include + +#include + +#include + +namespace { + +class TestSourceModel : public QAbstractListModel { + +public: + explicit TestSourceModel(QList roles) + : m_roles(std::move(roles)) + { + } + + QVariant data(const QModelIndex& index, int role) const override + { + if(!index.isValid() || index.row() >= capacity) + return {}; + + return 42; + } + + int rowCount(const QModelIndex& parent) const override + { + return capacity; + } + + QHash roleNames() const override + { + QHash roles; + roles.remove(m_roles.size()); + + for (auto i = 0; i < m_roles.size(); i++) + roles.insert(i, m_roles.at(i).toUtf8()); + + return roles; + } + +private: + static constexpr auto capacity = 5; + QList m_roles; +}; + +} + +class TestRolesRenamingModel: public QObject +{ + Q_OBJECT + +private slots: + void initializationTest() + { + RolesRenamingModel model; + + QQmlListProperty mapping = model.mapping(); + + RoleRename rename; + rename.setFrom("someIdFrom"); + rename.setTo("someIdTo"); + + mapping.append(&mapping, &rename); + + QTest::ignoreMessage(QtWarningMsg, "RolesRenamingModel: specified source roles not found: (\"someIdFrom\")!"); + + QCOMPARE(model.roleNames(), {}); + } + + void remappingTest() + { + TestSourceModel sourceModel({"id", "name", "color"}); + RolesRenamingModel model; + + QQmlListProperty mapping = model.mapping(); + + RoleRename rename_1; + rename_1.setFrom("id"); + rename_1.setTo("tokenId"); + mapping.append(&mapping, &rename_1); + + RoleRename rename_2; + rename_2.setFrom("name"); + rename_2.setTo("tokenName"); + mapping.append(&mapping, &rename_2); + + model.setSourceModel(&sourceModel); + + QHash expectedRoles = {{0, "tokenId"}, {1, "tokenName"}, {2, "color"}}; + QCOMPARE(model.roleNames(), expectedRoles); + } + + void addMappingAfterFetchingRoleNamesTest() + { + TestSourceModel sourceModel({"id", "name", "color"}); + RolesRenamingModel model; + + QQmlListProperty mapping = model.mapping(); + + RoleRename rename_1; + rename_1.setFrom("id"); + rename_1.setTo("tokenId"); + mapping.append(&mapping, &rename_1); + + model.setSourceModel(&sourceModel); + + QHash expectedRoles = {{0, "tokenId"}, {1, "name"}, {2, "color"}}; + QCOMPARE(model.roleNames(), expectedRoles); + + RoleRename rename_2; + rename_2.setFrom("name"); + rename_2.setTo("tokenName"); + + QTest::ignoreMessage(QtWarningMsg, "RolesRenamingModel: role names mapping cannot be modified after fetching role names!"); + mapping.append(&mapping, &rename_2); + + QCOMPARE(model.roleNames(), expectedRoles); + } + + void duplicatedNamesTest() + { + TestSourceModel sourceModel({"id", "name", "color"}); + RolesRenamingModel model; + + QQmlListProperty mapping = model.mapping(); + + RoleRename rename_1; + rename_1.setFrom("id"); + rename_1.setTo("name"); + mapping.append(&mapping, &rename_1); + + model.setSourceModel(&sourceModel); + + QTest::ignoreMessage(QtWarningMsg, "RolesRenamingModel: model cannot contain duplicated role names!"); + + QCOMPARE(model.roleNames(), {}); + } + + void resettingFromToPropertiesTest() + { + RoleRename rename; + + rename.setFrom("id"); + QCOMPARE(rename.from(), "id"); + QCOMPARE(rename.to(), ""); + + QTest::ignoreMessage(QtWarningMsg, + "RoleRename: property \"from\" is inteded to be initialized once and not changed!"); + rename.setFrom("id2"); + QCOMPARE(rename.from(), "id"); + QCOMPARE(rename.to(), ""); + + rename.setTo("myId"); + QCOMPARE(rename.from(), "id"); + QCOMPARE(rename.to(), "myId"); + + QTest::ignoreMessage(QtWarningMsg, "RoleRename: property \"to\" is inteded to be initialized once and not changed!"); + rename.setTo("myId2"); + QCOMPARE(rename.from(), "id"); + QCOMPARE(rename.to(), "myId"); + } + + void sourceModelDeletedTest() + { + auto sourceModel = std::make_unique( + QList{"id", "name", "color"}); + RolesRenamingModel model; + + QQmlListProperty mapping = model.mapping(); + + RoleRename rename_1; + rename_1.setFrom("id"); + rename_1.setTo("tokenId"); + mapping.append(&mapping, &rename_1); + + RoleRename rename_2; + rename_2.setFrom("name"); + rename_2.setTo("tokenName"); + mapping.append(&mapping, &rename_2); + + model.setSourceModel(sourceModel.get()); + + QHash expectedRoles = { + {0, "tokenId"}, {1, "tokenName"}, {2, "color"} + }; + QCOMPARE(model.roleNames(), expectedRoles); + QCOMPARE(model.rowCount(), 5); + + QCOMPARE(model.data(model.index(0, 0), 0), 42); + QCOMPARE(model.data(model.index(0, 0), 1), 42); + QCOMPARE(model.data(model.index(5, 0), 0), {}); + QCOMPARE(model.data(model.index(5, 0), 1), {}); + + QSignalSpy destroyedSpy(sourceModel.get(), &QObject::destroyed); + sourceModel.reset(); + + QCOMPARE(destroyedSpy.size(), 1); + + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.rowCount(), 0); + + QCOMPARE(model.roleNames(), {}); + QCOMPARE(model.data(model.index(0, 0), 0), {}); + QCOMPARE(model.data(model.index(0, 0), 1), {}); + QCOMPARE(model.data(model.index(5, 0), 0), {}); + QCOMPARE(model.data(model.index(5, 0), 1), {}); + } +}; + +QTEST_MAIN(TestRolesRenamingModel) +#include "tst_RolesRenamingModel.moc"