diff --git a/storybook/pages/NetworkSelectorViewPage.qml b/storybook/pages/NetworkSelectorViewPage.qml new file mode 100644 index 000000000..4b9b4666b --- /dev/null +++ b/storybook/pages/NetworkSelectorViewPage.qml @@ -0,0 +1,108 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import StatusQ 0.1 + +import Models 1.0 + +import AppLayouts.Wallet.views 1.0 + +SplitView { + id: root + + Pane { + id: mainPane + SplitView.fillWidth: true + SplitView.fillHeight: true + ColumnLayout { + anchors.fill: parent + Label { + text: "Radio Buttons" + font.bold: true + } + + NetworkSelectorView { + id: networkSelectionView + + Layout.fillWidth: true + Layout.fillHeight: true + + model: NetworksModel.flatNetworks + selection: [420] + showIndicator: true + multiSelection: false + } + + Label { + text: "Checkboxes" + font.bold: true + } + + NetworkSelectorView { + id: networkSelectionView2 + + Layout.fillWidth: true + Layout.fillHeight: true + + model: NetworksModel.flatNetworks + showIndicator: true + multiSelection: true + + selection: [1, 420] + } + } + } + + Pane { + id: controls + SplitView.preferredWidth: 300 + SplitView.fillHeight: true + Column { + anchors.fill: parent + Label { + text: "Simulate backend state" + font.bold: true + } + + Label { + text: "Radio buttons control" + } + Repeater { + model: NetworksModel.flatNetworks + delegate: CheckBox { + text: model.chainName + checked: networkSelectionView.selection.includes(model.chainId) + onToggled: { + if (checked) { + networkSelectionView.selection = [model.chainId] + } + } + } + } + + Label { + text: "Checkboxes control" + } + + Repeater { + model: NetworksModel.flatNetworks + delegate: CheckBox { + text: model.chainName + checked: networkSelectionView2.selection.includes(model.chainId) + onToggled: { + if (checked) { + const selection = networkSelectionView2.selection + selection.push(model.chainId) + networkSelectionView2.selection = selection + } else { + networkSelectionView2.selection = networkSelectionView2.selection.filter((id) => id !== model.chainId) + } + } + } + } + } + } +} + +// category: Views \ No newline at end of file diff --git a/storybook/qmlTests/tests/tst_NetworkSelectorView.qml b/storybook/qmlTests/tests/tst_NetworkSelectorView.qml new file mode 100644 index 000000000..a584e3524 --- /dev/null +++ b/storybook/qmlTests/tests/tst_NetworkSelectorView.qml @@ -0,0 +1,343 @@ +import QtQuick 2.15 +import QtTest 1.15 + +import AppLayouts.Wallet.views 1.0 + +import utils 1.0 + +import Models 1.0 + + +Item { + id: root + width: 600 + height: 400 + + Component { + id: componentUnderTest + NetworkSelectorView { + anchors.centerIn: parent + model: NetworksModel.flatNetworks + } + } + + SignalSpy { + id: toggleNetworkSpy + target: controlUnderTest + signalName: "toggleNetwork" + } + + SignalSpy { + id: selectionChangedSpy + target: controlUnderTest + signalName: "onSelectionChanged" + } + + property NetworkSelectorView controlUnderTest: null + + TestCase { + name: "NetworkSelectorView" + when: windowShown + + function init() { + controlUnderTest = createTemporaryObject(componentUnderTest, root) + toggleNetworkSpy.clear() + selectionChangedSpy.clear() + } + + function test_basicGeometry() { + verify(!!controlUnderTest) + verify(controlUnderTest.width > 0) + verify(controlUnderTest.height > 0) + } + + function test_defaultConfiguration() { + // Default configuration: + // - model is not empty + // - showIndicator is true + // - multiSelection is false + // - interactive is true + // - selection has length 1. This is because the single selection mode is enabled by default + + verify(controlUnderTest.model.count > 0) + verify(controlUnderTest.showIndicator) + verify(!controlUnderTest.multiSelection) + verify(controlUnderTest.interactive) + verify(controlUnderTest.selection.length === 1) + } + + function test_defaultDelegate() { + // iterate the model and check: + // - a delegate is created for each item + // - the delegate has the correct chain name + // - the delegate has the correct icon url + // - the delegate has the correct show indicator value + // - the delegate has the correct multi selection value + // - the delegate has the correct check state + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + + verify(!!delegate) + compare(delegate.title, model.chainName) + compare(delegate.iconUrl, Style.svg(model.iconUrl)) + compare(delegate.showIndicator, controlUnderTest.showIndicator) + compare(delegate.multiSelection, controlUnderTest.multiSelection) + compare(delegate.checkState, controlUnderTest.selection.includes(model.chainId) ? Qt.Checked : Qt.Unchecked) + } + + controlUnderTest = createTemporaryObject(componentUnderTest, root, {multiSelection: true}) + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + + compare(delegate.showIndicator, controlUnderTest.showIndicator) + compare(delegate.multiSelection, controlUnderTest.multiSelection) + compare(delegate.checkState, Qt.Unchecked) + } + } + + function test_selectionBindingsSingleSelection() { + // 1. toggle by click + // 2. toggle by updating the selection property + // 3. toggle by click + // 4. toggle by updating the selection property + + let delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(1).chainName) + + // 1. toggle by click + mouseClick(delegate) + compare(toggleNetworkSpy.count, 1) + compare(selectionChangedSpy.count, 1) + compare(delegate.checkState, Qt.Checked) + + // 2. toggle by updating the selection property + controlUnderTest.selection = [controlUnderTest.model.get(2).chainId] + compare(toggleNetworkSpy.count, 1) + compare(selectionChangedSpy.count, 2) + compare(controlUnderTest.selection.length, 1) + compare(controlUnderTest.selection[0], controlUnderTest.model.get(2).chainId) + compare(delegate.checkState, Qt.Unchecked) + delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(2).chainName) + compare(delegate.checkState, Qt.Checked) + + // 3. toggle by click + const newSelectionDelegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(1).chainName) + mouseClick(newSelectionDelegate) + compare(toggleNetworkSpy.count, 2) + compare(selectionChangedSpy.count, 3) + compare(delegate.checkState, Qt.Unchecked) + compare(newSelectionDelegate.checkState, Qt.Checked) + + // 4. toggle by updating the selection property + controlUnderTest.selection = [controlUnderTest.model.get(2).chainId] + compare(toggleNetworkSpy.count, 2) + compare(selectionChangedSpy.count, 4) + compare(controlUnderTest.selection.length, 1) + compare(controlUnderTest.selection[0], controlUnderTest.model.get(2).chainId) + compare(delegate.checkState, Qt.Checked) + compare(newSelectionDelegate.checkState, Qt.Unchecked) + } + + function test_selectionBindingMultiSelection() { + // 1. toggle by click + // 2. toggle by updating the selection property + // 3. toggle by click + // 4. toggle by updating the selection property + + controlUnderTest.multiSelection = true + waitForItemPolished(controlUnderTest) + + let delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(1).chainName) + + // 1. toggle by click + mouseClick(delegate) + compare(toggleNetworkSpy.count, 1) + compare(selectionChangedSpy.count, 1) + compare(delegate.checkState, Qt.Checked) + + // 2. toggle by updating the selection property + controlUnderTest.selection = [controlUnderTest.model.get(1).chainId, controlUnderTest.model.get(2).chainId] + compare(toggleNetworkSpy.count, 1) + compare(selectionChangedSpy.count, 2) + compare(controlUnderTest.selection.length, 2) + compare(controlUnderTest.selection[0], controlUnderTest.model.get(1).chainId) + compare(controlUnderTest.selection[1], controlUnderTest.model.get(2).chainId) + compare(delegate.checkState, Qt.Checked) + delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(2).chainName) + compare(delegate.checkState, Qt.Checked) + + // 3. toggle by click + const newSelectionDelegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(1).chainName) + mouseClick(newSelectionDelegate) + compare(toggleNetworkSpy.count, 2) + compare(selectionChangedSpy.count, 3) + compare(delegate.checkState, Qt.Checked) + compare(newSelectionDelegate.checkState, Qt.Unchecked) + mouseClick(newSelectionDelegate) + compare(newSelectionDelegate.checkState, Qt.Checked) + + // 4. toggle by updating the selection property + controlUnderTest.selection = [controlUnderTest.model.get(2).chainId] + compare(toggleNetworkSpy.count, 3) + compare(selectionChangedSpy.count, 5) + compare(controlUnderTest.selection.length, 1) + compare(controlUnderTest.selection[0], controlUnderTest.model.get(2).chainId) + compare(delegate.checkState, Qt.Checked) + compare(newSelectionDelegate.checkState, Qt.Unchecked) + mouseClick(delegate) + compare(toggleNetworkSpy.count, 4) + compare(delegate.checkState, Qt.Unchecked) + compare(controlUnderTest.selection.length, 0) + + // 5. select all by click + for (var i = 0; i < controlUnderTest.model.count; i++) { + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(i).chainName) + mouseClick(delegate) + } + + compare(controlUnderTest.selection.length, controlUnderTest.model.count) + compare(toggleNetworkSpy.count, controlUnderTest.model.count + 4) + toggleNetworkSpy.clear() + selectionChangedSpy.clear() + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(i).chainName) + compare(delegate.checkState, Qt.PartiallyChecked) + } + + // 6. set the selection to all selected + const selection = [...controlUnderTest.selection] + controlUnderTest.selection = selection + + compare(toggleNetworkSpy.count, 0) + compare(selectionChangedSpy.count, 1) + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(i).chainName) + compare(delegate.checkState, Qt.PartiallyChecked) + } + + // 7. deselect and select again the same item + mouseClick(findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(0).chainName)) + compare(toggleNetworkSpy.count, 1) + compare(selectionChangedSpy.count, 2) + compare(controlUnderTest.selection.length, controlUnderTest.model.count - 1) + compare(findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(0).chainName).checkState, Qt.Unchecked) + + mouseClick(findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(0).chainName)) + compare(toggleNetworkSpy.count, 2) + compare(selectionChangedSpy.count, 3) + compare(controlUnderTest.selection.length, controlUnderTest.model.count) + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(i).chainName) + compare(delegate.checkState, Qt.PartiallyChecked) + } + + // 8. deselect one by setting the selection and select all again + let selection2 = [...controlUnderTest.selection] + const deletedId = selection2.splice(0, 1) + + controlUnderTest.selection = selection2 + compare(toggleNetworkSpy.count, 2) + compare(selectionChangedSpy.count, 4) + compare(controlUnderTest.selection.length, controlUnderTest.model.count - 1) + + for (var i = 1; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + compare(delegate.checkState, model.chainId === deletedId[0] ? Qt.Unchecked : Qt.Checked) + } + + selection2 = [...controlUnderTest.selection, deletedId[0]] + controlUnderTest.selection = selection2 + compare(toggleNetworkSpy.count, 2) + compare(selectionChangedSpy.count, 5) + compare(controlUnderTest.selection.length, controlUnderTest.model.count) + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + controlUnderTest.model.get(i).chainName) + compare(delegate.checkState, Qt.PartiallyChecked) + } + } + + function test_noIndicatorConfig() { + controlUnderTest.showIndicator = false + waitForRendering(controlUnderTest) + waitForItemPolished(controlUnderTest) + + for (let multiSelect = 0; multiSelect < 2; multiSelect++) { + controlUnderTest.multiSelection = multiSelect + waitForRendering(controlUnderTest) + waitForItemPolished(controlUnderTest) + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + + compare(delegate.showIndicator, controlUnderTest.showIndicator) + + const checkBox = findChild(delegate, "networkSelectionCheckbox_" + model.chainName) + const radioButton = findChild(delegate, "networkSelectionRadioButton_" + model.chainName) + + verify(!checkBox) + verify(!radioButton) + } + } + + controlUnderTest.showIndicator = true + waitForRendering(controlUnderTest) + waitForItemPolished(controlUnderTest) + + for (let multiSelect = 0; multiSelect < 2; multiSelect++) { + controlUnderTest.multiSelection = multiSelect + waitForRendering(controlUnderTest) + waitForItemPolished(controlUnderTest) + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + + compare(delegate.showIndicator, controlUnderTest.showIndicator) + + const checkBox = findChild(delegate, "networkSelectionCheckbox_" + model.chainName) + const radioButton = findChild(delegate, "networkSelectionRadioButton_" + model.chainName) + if (multiSelect) { + verify(!!checkBox) + verify(!radioButton) + } else { + verify(!checkBox) + verify(!!radioButton) + } + } + } + } + + function test_interactiveConfig() { + controlUnderTest.interactive = false + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + + mouseClick(delegate) + compare(toggleNetworkSpy.count, 0) + compare(selectionChangedSpy.count, 0) + } + + controlUnderTest.interactive = true + + for (var i = 0; i < controlUnderTest.model.count; i++) { + const model = controlUnderTest.model.get(i) + const delegate = findChild(controlUnderTest, "networkSelectorDelegate_" + model.chainName) + + mouseClick(delegate) + compare(toggleNetworkSpy.count, i + 1) + compare(selectionChangedSpy.count, i + 1) + } + } + } +} \ No newline at end of file diff --git a/ui/app/AppLayouts/Wallet/views/NetworkSelectorView.qml b/ui/app/AppLayouts/Wallet/views/NetworkSelectorView.qml new file mode 100644 index 000000000..86b311798 --- /dev/null +++ b/ui/app/AppLayouts/Wallet/views/NetworkSelectorView.qml @@ -0,0 +1,142 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQml 2.15 +import QtQml.Models 2.15 + +import StatusQ 0.1 +import StatusQ.Core 0.1 +import StatusQ.Core.Theme 0.1 +import StatusQ.Core.Utils 0.1 + +import utils 1.0 + +import "../controls" + +StatusListView { + id: root + /** + Model is expected to be sorted by layer + Expected model structure: + chainName [string] - chain long name. e.g. "Ethereum" or "Optimism" + chainId [int] - chain unique identifier + iconUrl [string] - SVG icon name. e.g. "network/Network=Ethereum" + layer [int] - chain layer. e.g. 1 or 2 + **/ + property bool showIndicator: true + property bool multiSelection: false + property bool interactive: true + + /** + The list selected of chain ids + It is a read/write property + WARNING: Update the array, not the internal content + **/ + property var selection: [] + + signal toggleNetwork(int chainId, int index) + + onSelectionChanged: { + if (!root.multiSelection && selection.length > 1) { + console.warn("Warning: Multi-selection is disabled, but multiple items are selected. Automatically selecting the last inserted item.") + selection = [selection[selection.length - 1]] + } + } + + onMultiSelectionChanged: { + if (root.multiSelection) return; + + // When changing the multi-selection mode, we need to ensure that the selection is valid + if (root.selection.length > 1) { + root.selection = [root.selection[0]] + } + + // Single selection defaults to first item if no selection is made + if (root.selection.length === 0 && root.count > 0) { + root.selection = [ModelUtils.get(root.model, 0).chainId] + } + } + implicitWidth: 300 + implicitHeight: contentHeight + + spacing: 4 + delegate: NetworkSelectItemDelegate { + id: delegateItem + + required property var model + required property int index + + readonly property bool inSelection: root.selection.includes(model.chainId) + + objectName: "networkSelectorDelegate_" + model.chainName + height: 48 + width: ListView.view.width + title: model.chainName + iconUrl: Style.svg(model.iconUrl) + showIndicator: root.showIndicator + multiSelection: root.multiSelection + interactive: root.interactive + + checkState: inSelection ? (d.allSelected && root.interactive ? Qt.PartiallyChecked : Qt.Checked) : Qt.Unchecked + nextCheckState: checkState + onToggled: { + d.onToggled(checkState, model.chainId) + root.toggleNetwork(model.chainId, index) + } + + Binding on checkState { + when: root.multiSelection && d.allSelected && root.interactive + value: Qt.PartiallyChecked + restoreMode: Binding.RestoreBindingOrValue + } + + Binding on checkState { + value: inSelection ? (d.allSelected && root.interactive ? Qt.PartiallyChecked : Qt.Checked) : Qt.Unchecked + } + } + + section { + property: "layer" + delegate: Loader { + required property int section + width: parent.width + height: active ? 44 : 0 + sourceComponent: section === 2 ? layer2text: null + + Component { + id: layer2text + StatusBaseText { + color: Theme.palette.baseColor1 + text: qsTr("Layer 2") + leftPadding: 16 + topPadding: 14 + } + } + } + } + + QtObject { + id: d + readonly property bool allSelected: root.selection.length === root.count + + function onToggled(initialState, chainId) { + let selection = root.selection + if (initialState === Qt.Unchecked && initialState !== Qt.PartiallyChecked) { + if (!root.multiSelection) + selection = [] + + selection.push(chainId) + } else if (root.multiSelection) { + selection = selection.filter((id) => id !== chainId) + } + + root.selection = [...selection] + } + } + + Component.onCompleted: { + if(root.selection.length === 0 && !root.multiSelection && root.count > 0) { + const firstChain = ModelUtils.get(root.model, 0).chainId + root.selection = [firstChain] + } + } +} diff --git a/ui/app/AppLayouts/Wallet/views/qmldir b/ui/app/AppLayouts/Wallet/views/qmldir index f64560e1e..425b22e63 100644 --- a/ui/app/AppLayouts/Wallet/views/qmldir +++ b/ui/app/AppLayouts/Wallet/views/qmldir @@ -1,6 +1,7 @@ AssetsDetailView 1.0 AssetsDetailView.qml CollectiblesView 1.0 CollectiblesView.qml NetworkSelectionView 1.0 NetworkSelectionView.qml +NetworkSelectorView 1.0 NetworkSelectorView.qml SavedAddresses 1.0 SavedAddresses.qml TokenSelectorAssetDelegate 1.0 TokenSelectorAssetDelegate.qml TokenSelectorView 1.0 TokenSelectorView.qml