From 587594f3b9c96f080c806abfc7f30293851ef3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Cie=C5=9Blak?= Date: Tue, 7 May 2024 16:18:49 +0200 Subject: [PATCH] feat(StatusQ): General purpose js function-based aggregator Closes: #14617 --- storybook/pages/FunctionAggregatorPage.qml | 155 ++++++++++++++++++ storybook/src/Storybook/GenericListView.qml | 9 + ui/StatusQ/CMakeLists.txt | 2 + .../include/StatusQ/functionaggregator.h | 38 +++++ ui/StatusQ/src/functionaggregator.cpp | 98 +++++++++++ ui/StatusQ/src/plugin.cpp | 2 + ui/StatusQ/tests/CMakeLists.txt | 4 + ui/StatusQ/tests/tst_FunctionAggregator.cpp | 147 +++++++++++++++++ 8 files changed, 455 insertions(+) create mode 100644 storybook/pages/FunctionAggregatorPage.qml create mode 100644 ui/StatusQ/include/StatusQ/functionaggregator.h create mode 100644 ui/StatusQ/src/functionaggregator.cpp create mode 100644 ui/StatusQ/tests/tst_FunctionAggregator.cpp diff --git a/storybook/pages/FunctionAggregatorPage.qml b/storybook/pages/FunctionAggregatorPage.qml new file mode 100644 index 0000000000..9946368450 --- /dev/null +++ b/storybook/pages/FunctionAggregatorPage.qml @@ -0,0 +1,155 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 +import StatusQ.Core.Utils 0.1 + +import Storybook 1.0 + + +Control { + id: root + + font.pixelSize: 16 + padding: 10 + + ListModel { + id: sourceModel + + ListElement { + symbol: "SNT" + balance: "4" + } + ListElement { + symbol: "ETH" + balance: "14" + } + ListElement { + symbol: "ZRX" + balance: "24" + } + ListElement { + symbol: "DAI" + balance: "43" + } + ListElement { + symbol: "UNI" + balance: "2" + } + ListElement { + symbol: "PEPE" + balance: "1" + } + } + + FunctionAggregator { + id: totalBalanceAggregator + + model: sourceModel + initialValue: "0" + roleName: "balance" + + aggregateFunction: (aggr, value) => AmountsArithmetic.sum( + AmountsArithmetic.fromString(aggr), + AmountsArithmetic.fromString(value)).toString() + } + + FunctionAggregator { + id: maxBalanceAggregator + + model: sourceModel + initialValue: "0" + roleName: "balance" + + aggregateFunction: (aggr, value) => AmountsArithmetic.cmp( + AmountsArithmetic.fromString(aggr), + AmountsArithmetic.fromString(value)) > 0 + ? aggr : value + } + + FunctionAggregator { + id: tokensListAggregator + + model: sourceModel + initialValue: [] + roleName: "symbol" + + aggregateFunction: (aggr, value) => [...aggr, value] + } + + contentItem: ColumnLayout { + Label { + text: "SUMMARY" + font.bold: true + } + + Label { + text: "Total balance: " + totalBalanceAggregator.value + } + + Label { + text: "Max balance: " + maxBalanceAggregator.value + } + + Label { + text: "Tokens list: " + tokensListAggregator.value + } + + Item { + Layout.preferredHeight: 20 + } + + Label { + text: "MODEL (click rows to change)" + font.bold: true + } + + GenericListView { + + Layout.fillWidth: true + Layout.fillHeight: true + + model: sourceModel + + onRowClicked: { + if (role === "balance") { + const balance = sourceModel.get(index).balance + + sourceModel.setProperty(index, "balance", + (Number(balance) + 1).toString()) + } else { + const symbol = sourceModel.get(index).symbol + + sourceModel.setProperty(index, "symbol", symbol + "_") + } + } + + insetComponent: Button { + height: 20 + font.pixelSize: 11 + text: "remove" + + onClicked: { + sourceModel.remove(model.index) + } + } + } + + Button { + text: "Add token" + + property int counter: 1 + + onClicked: { + sourceModel.append({ + symbol: "NEW_" + counter, + balance: "" + counter * 2 + }) + counter++ + } + } + } +} + +// category: Models diff --git a/storybook/src/Storybook/GenericListView.qml b/storybook/src/Storybook/GenericListView.qml index ee7ed47bbb..8fb02b8926 100644 --- a/storybook/src/Storybook/GenericListView.qml +++ b/storybook/src/Storybook/GenericListView.qml @@ -37,6 +37,7 @@ ListView { bottomMargin: margin signal moveRequested(int from, int to) + signal rowClicked(int index, string role) ListModel { id: rowModel @@ -154,6 +155,14 @@ ListView { readonly property string separator: last ? "" : "," text: `${roleName}: ${valueSanitized}${separator}` + + MouseArea { + anchors.fill: parent + + onClicked: root.rowClicked( + delegateRoot.topModel.index, + roleName) + } } } } diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index e6b55e9f8b..c7d1b651c5 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -99,6 +99,7 @@ add_library(StatusQ SHARED include/StatusQ/fastexpressionrole.h include/StatusQ/fastexpressionsorter.h include/StatusQ/formatteddoubleproperty.h + include/StatusQ/functionaggregator.h include/StatusQ/leftjoinmodel.h include/StatusQ/modelutilsinternal.h include/StatusQ/movablemodel.h @@ -120,6 +121,7 @@ add_library(StatusQ SHARED src/fastexpressionrole.cpp src/fastexpressionsorter.cpp src/formatteddoubleproperty.cpp + src/functionaggregator.cpp src/leftjoinmodel.cpp src/modelutilsinternal.cpp src/movablemodel.cpp diff --git a/ui/StatusQ/include/StatusQ/functionaggregator.h b/ui/StatusQ/include/StatusQ/functionaggregator.h new file mode 100644 index 0000000000..53f97d45e0 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/functionaggregator.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +#include "StatusQ/singleroleaggregator.h" + +class FunctionAggregator : public SingleRoleAggregator +{ + Q_OBJECT + + Q_PROPERTY(QVariant initialValue READ initialValue WRITE setInitialValue + NOTIFY initialValueChanged) + + Q_PROPERTY(QJSValue aggregateFunction READ aggregateFunction + WRITE setAggregateFunction NOTIFY aggregateFunctionChanged) + +public: + explicit FunctionAggregator(QObject* parent = nullptr); + + const QVariant& initialValue() const; + void setInitialValue(const QVariant& initialValue); + + const QJSValue& aggregateFunction() const; + void setAggregateFunction(const QJSValue& aggregateFunction); + +signals: + void initialValueChanged(); + void aggregateFunctionChanged(); + +protected slots: + QVariant calculateAggregation() override; + +private: + QVariant m_initialValue; + QJSValue m_aggregateFunction; +}; diff --git a/ui/StatusQ/src/functionaggregator.cpp b/ui/StatusQ/src/functionaggregator.cpp new file mode 100644 index 0000000000..5a3588d71e --- /dev/null +++ b/ui/StatusQ/src/functionaggregator.cpp @@ -0,0 +1,98 @@ +#include "StatusQ/functionaggregator.h" + +#include +#include + +FunctionAggregator::FunctionAggregator(QObject* parent) + : SingleRoleAggregator(parent) +{ + recalculate(); +} + +const QVariant& FunctionAggregator::initialValue() const +{ + return m_initialValue; +} + +void FunctionAggregator::setInitialValue(const QVariant& initialValue) +{ + if (m_initialValue == initialValue) + return; + + m_initialValue = initialValue; + + emit initialValueChanged(); + recalculate(); +} + +const QJSValue& FunctionAggregator::aggregateFunction() const +{ + return m_aggregateFunction; +} + +void FunctionAggregator::setAggregateFunction(const QJSValue& aggregateFunction) +{ + if (m_aggregateFunction.strictlyEquals(aggregateFunction)) + return; + + if (!aggregateFunction.isCallable() && !aggregateFunction.isUndefined()) { + qWarning() << "FunctionAggregator::aggregateFunction must be a " + "callable object."; + return; + } + + m_aggregateFunction = aggregateFunction; + + emit aggregateFunctionChanged(); + recalculate(); +} + +QVariant FunctionAggregator::calculateAggregation() +{ + // Check if m_model exists and role name is initialized + if (!model() || roleName().isEmpty()) + return m_initialValue; + + // Check if m_roleName is part of the roles of the model + QHash roles = model()->roleNames(); + if (!roleExists() && model()->rowCount()) { + qWarning() << "Provided role name does not exist in the current model."; + return m_initialValue; + } + + if (!m_initialValue.isValid()) + return m_initialValue; + + if (m_aggregateFunction.isUndefined()) + return m_initialValue; + + QJSEngine* engine = qjsEngine(this); + + if (engine == nullptr) { + qWarning() << "FunctionAggregator is intended to be used in JS " + "environment. QJSEngine must be available."; + return m_initialValue; + } + + QJSValue aggregation = engine->toScriptValue(m_initialValue); + + auto rows = model()->rowCount(); + auto role = roles.key(roleName()); + + for (int i = 0; i < rows; ++i) { + QModelIndex index = model()->index(i, 0); + QVariant value = model()->data(index, role); + + QJSValue valueJs = engine->toScriptValue(value); + + aggregation = m_aggregateFunction.call({aggregation, valueJs}); + + if (aggregation.isError()) { + qWarning() << "Aggregation calculation failed. Error type:" + << aggregation.errorType(); + return m_initialValue; + } + } + + return aggregation.toVariant(); +} diff --git a/ui/StatusQ/src/plugin.cpp b/ui/StatusQ/src/plugin.cpp index c1377be9b8..54f395f74d 100644 --- a/ui/StatusQ/src/plugin.cpp +++ b/ui/StatusQ/src/plugin.cpp @@ -9,6 +9,7 @@ #include "StatusQ/fastexpressionrole.h" #include "StatusQ/fastexpressionsorter.h" #include "StatusQ/formatteddoubleproperty.h" +#include "StatusQ/functionaggregator.h" #include "StatusQ/leftjoinmodel.h" #include "StatusQ/modelutilsinternal.h" #include "StatusQ/movablemodel.h" @@ -56,6 +57,7 @@ public: qmlRegisterType("StatusQ", 0, 1, "RoleRename"); qmlRegisterType("StatusQ", 0, 1, "RolesRenamingModel"); qmlRegisterType("StatusQ", 0, 1, "SumAggregator"); + qmlRegisterType("StatusQ", 0, 1, "FunctionAggregator"); qmlRegisterType("StatusQ", 0, 1, "WritableProxyModel"); qmlRegisterType("StatusQ", 0, 1, "FormattedDoubleProperty"); diff --git a/ui/StatusQ/tests/CMakeLists.txt b/ui/StatusQ/tests/CMakeLists.txt index 097fd10cfb..bf84b38e73 100644 --- a/ui/StatusQ/tests/CMakeLists.txt +++ b/ui/StatusQ/tests/CMakeLists.txt @@ -82,6 +82,10 @@ add_executable(SumAggregatorTest tst_SumAggregator.cpp) target_link_libraries(SumAggregatorTest PRIVATE StatusQ StatusQTestLib) add_test(NAME SumAggregatorTest COMMAND SumAggregatorTest) +add_executable(FunctionAggregatorTest tst_FunctionAggregator.cpp) +target_link_libraries(FunctionAggregatorTest PRIVATE StatusQ StatusQTestLib) +add_test(NAME FunctionAggregatorTest COMMAND FunctionAggregatorTest) + add_executable(ConcatModelTest tst_ConcatModel.cpp) target_link_libraries(ConcatModelTest PRIVATE StatusQ StatusQTestLib SortFilterProxyModel) add_test(NAME ConcatModelTest COMMAND ConcatModelTest) diff --git a/ui/StatusQ/tests/tst_FunctionAggregator.cpp b/ui/StatusQ/tests/tst_FunctionAggregator.cpp new file mode 100644 index 0000000000..7c895d0712 --- /dev/null +++ b/ui/StatusQ/tests/tst_FunctionAggregator.cpp @@ -0,0 +1,147 @@ +#include + +#include + +#include +#include + +class TestFunctionAggregator : public QObject +{ + Q_OBJECT + +private: + void makeQmlEngineAvailable(QQmlEngine& engine, QObject& obj) + { + auto jsObj = engine.newQObject(&obj); + engine.setObjectOwnership(&obj, QQmlEngine::CppOwnership); + Q_UNUSED(jsObj); + } + +private slots: + void basicTest() { + QQmlEngine engine; + FunctionAggregator aggregator; + makeQmlEngineAvailable(engine, aggregator); + + auto jsLambda = engine.evaluate("(aggr, val) => [...aggr, val]"); + QCOMPARE(jsLambda.isError(), false); + QCOMPARE(jsLambda.isCallable(), true); + + TestModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "4", "3", "5", "5" }} + }); + + aggregator.setModel(&sourceModel); + aggregator.setRoleName("balance"); + aggregator.setInitialValue(QVariantList()); + aggregator.setAggregateFunction(jsLambda); + + QVariantList expected{"4", "3", "5", "5"}; + QCOMPARE(aggregator.value(), expected); + } + + void typeMismatchTest() { + QQmlEngine engine; + FunctionAggregator aggregator; + makeQmlEngineAvailable(engine, aggregator); + + auto jsLambda = engine.evaluate("(aggr, val) => [...aggr, val]"); + QCOMPARE(jsLambda.isError(), false); + QCOMPARE(jsLambda.isCallable(), true); + + TestModel sourceModel({{ "balance", { "4", "3", "5", "5" }}}); + + aggregator.setModel(&sourceModel); + aggregator.setRoleName("balance"); + aggregator.setInitialValue(0); + + QTest::ignoreMessage(QtWarningMsg, + "Aggregation calculation failed. Error type: 6"); + + aggregator.setAggregateFunction(jsLambda); + + QCOMPARE(aggregator.value(), 0); + } + + void roleNameNotFoundTest() { + QQmlEngine engine; + FunctionAggregator aggregator; + makeQmlEngineAvailable(engine, aggregator); + + TestModel sourceModel({{ "balance", { "4", "3", "5", "5" }}}); + aggregator.setModel(&sourceModel); + aggregator.setInitialValue(0); + + QTest::ignoreMessage(QtWarningMsg, + "Provided role name does not exist in the current model."); + + aggregator.setRoleName("notExisiting"); + QCOMPARE(aggregator.value(), 0); + } + + void invalidFunctionTest() { + FunctionAggregator aggregator; + + QTest::ignoreMessage(QtWarningMsg, + "FunctionAggregator::aggregateFunction must be a " + "callable object."); + aggregator.setAggregateFunction(5); + } + + void noJsEngineTest() { + QQmlEngine engine; + FunctionAggregator aggregator; + + auto jsLambda = engine.evaluate("(aggr) => aggr"); + QCOMPARE(jsLambda.isError(), false); + QCOMPARE(jsLambda.isCallable(), true); + + TestModel sourceModel({ + { "balance", { "4", "3", "5", "5" }} + }); + + aggregator.setModel(&sourceModel); + aggregator.setRoleName("balance"); + aggregator.setInitialValue(0); + + QTest::ignoreMessage(QtWarningMsg, + "FunctionAggregator is intended to be used in JS " + "environment. QJSEngine must be available."); + + aggregator.setAggregateFunction(jsLambda); + QCOMPARE(aggregator.value(), 0); + } + + void providingInitialValueIfNotReadyTest() { + QQmlEngine engine; + FunctionAggregator aggregator; + makeQmlEngineAvailable(engine, aggregator); + + auto jsLambda = engine.evaluate("(aggr, val) => aggr + val"); + QCOMPARE(jsLambda.isError(), false); + QCOMPARE(jsLambda.isCallable(), true); + + TestModel sourceModel({ + { "chainId", { "12", "13", "1", "321" }}, + { "balance", { "4", "3", "5", "5" }} + }); + + QCOMPARE(aggregator.value(), {}); + + aggregator.setInitialValue("-"); + QCOMPARE(aggregator.value(), "-"); + + aggregator.setModel(&sourceModel); + QCOMPARE(aggregator.value(), "-"); + + aggregator.setRoleName("balance"); + QCOMPARE(aggregator.value(), "-"); + + aggregator.setAggregateFunction(jsLambda); + QCOMPARE(aggregator.value(), "-4355"); + } +}; + +QTEST_MAIN(TestFunctionAggregator) +#include "tst_FunctionAggregator.moc"