mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-08-25 06:01:11 +00:00
feat(wallet): add reusable ProgramAccountSelector component
A shared QML select box for choosing a program account (a token holding) that matches given criteria (accountType + a state field such as the token definitionId), listing matches with balances and emitting selectionChanged. Registered in the wallet module's public QML. Cherry-picked from feat/shared-wallet-token-holding-selector, reduced to the reusable component only; the AMM swap/liquidity wiring and FFI account source are reimplemented on the current (post swap + createPool) code.
This commit is contained in:
committed by
r4bbit
parent
02e77032b7
commit
e0ae3208a1
@@ -67,6 +67,7 @@ if(LOGOS_WALLET_BUILD_QML)
|
||||
)
|
||||
set(wallet_public_qml
|
||||
qml/WalletControl.qml
|
||||
qml/ProgramAccountSelector.qml
|
||||
qml/TransactionConfirmationDialog.qml
|
||||
qml/SubmittedTransaction.qml
|
||||
)
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
enum SelectionMode {
|
||||
Input,
|
||||
Output
|
||||
}
|
||||
|
||||
property var sourceModel: []
|
||||
property string accountType: ""
|
||||
property string stateField: ""
|
||||
property var stateValue: ""
|
||||
property int selectionMode: ProgramAccountSelector.Input
|
||||
property string selectedAccountId: ""
|
||||
property bool createNewSelected: false
|
||||
property string createNewText: qsTr("Create new account")
|
||||
property string emptyInputText: qsTr("No funds")
|
||||
property string placeholderText: qsTr("Select account")
|
||||
property string criteriaPendingText: qsTr("Select token first")
|
||||
property string accessibleName: qsTr("Program account")
|
||||
property color backgroundColor: "#27272a"
|
||||
property color hoverColor: "#3f3f46"
|
||||
property color textColor: "#f4f4f5"
|
||||
property color secondaryTextColor: "#a1a1aa"
|
||||
property color borderColor: "#52525b"
|
||||
property color focusColor: "#f26a21"
|
||||
property int modelRevision: 0
|
||||
|
||||
readonly property bool criteriaReady: root.accountType.length > 0
|
||||
&& (root.stateField.length === 0
|
||||
|| root.scalarText(root.stateValue).length > 0)
|
||||
readonly property var matchingAccounts: root.filteredAccounts()
|
||||
readonly property var choices: root.choiceRows()
|
||||
readonly property bool hasFunds: root.matchingAccounts.length > 0
|
||||
readonly property bool selectionValid: root.accountById(root.selectedAccountId) !== null
|
||||
readonly property bool ready: root.criteriaReady
|
||||
&& (root.selectionValid
|
||||
|| (root.selectionMode === ProgramAccountSelector.Output
|
||||
&& root.createNewSelected))
|
||||
readonly property var selectedAccount: root.accountById(root.selectedAccountId)
|
||||
readonly property string selectedBalanceRaw: root.selectedAccount
|
||||
? String(root.valueFor(
|
||||
root.selectedAccount,
|
||||
"balanceRaw") || "0")
|
||||
: "0"
|
||||
readonly property bool showCombo: root.selectionMode === ProgramAccountSelector.Output
|
||||
|| (root.criteriaReady
|
||||
&& root.matchingAccounts.length > 1)
|
||||
readonly property bool showEmptyInput: root.selectionMode === ProgramAccountSelector.Input
|
||||
&& root.criteriaReady
|
||||
&& root.matchingAccounts.length === 0
|
||||
|
||||
signal selectionChanged(string accountId, bool createNew)
|
||||
|
||||
implicitWidth: 220
|
||||
implicitHeight: root.showCombo ? 34 : root.showEmptyInput ? 20 : 0
|
||||
visible: implicitHeight > 0
|
||||
|
||||
Instantiator {
|
||||
id: rows
|
||||
|
||||
model: root.sourceModel
|
||||
delegate: QtObject {
|
||||
required property var model
|
||||
required property var modelData
|
||||
|
||||
readonly property var accountRow: {
|
||||
if (modelData !== null && typeof modelData === "object") {
|
||||
return modelData
|
||||
}
|
||||
if (model === null || typeof model !== "object")
|
||||
return ({})
|
||||
return {
|
||||
"accountId": model.accountId,
|
||||
"address": model.address,
|
||||
"displayAddress": model.displayAddress,
|
||||
"accountType": model.accountType,
|
||||
"definitionId": model.definitionId,
|
||||
"balanceRaw": model.balanceRaw,
|
||||
"state": model.state
|
||||
}
|
||||
}
|
||||
}
|
||||
onObjectAdded: function(index, object) {
|
||||
++root.modelRevision
|
||||
Qt.callLater(root.reconcileSelection)
|
||||
}
|
||||
onObjectRemoved: function(index, object) {
|
||||
++root.modelRevision
|
||||
Qt.callLater(root.reconcileSelection)
|
||||
}
|
||||
}
|
||||
|
||||
onSourceModelChanged: Qt.callLater(root.reconcileSelection)
|
||||
onAccountTypeChanged: Qt.callLater(root.reconcileSelection)
|
||||
onStateFieldChanged: Qt.callLater(root.reconcileSelection)
|
||||
onStateValueChanged: Qt.callLater(root.reconcileSelection)
|
||||
onSelectionModeChanged: Qt.callLater(root.reconcileSelection)
|
||||
Component.onCompleted: Qt.callLater(root.reconcileSelection)
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: root.showEmptyInput
|
||||
text: root.emptyInputText
|
||||
color: root.secondaryTextColor
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Accessible.role: Accessible.StaticText
|
||||
Accessible.name: text
|
||||
}
|
||||
|
||||
ComboBox {
|
||||
id: accountCombo
|
||||
|
||||
objectName: "programAccountComboBox"
|
||||
anchors.fill: parent
|
||||
visible: root.showCombo
|
||||
enabled: root.enabled && root.criteriaReady && root.choices.length > 0
|
||||
model: root.choices
|
||||
currentIndex: root.choiceIndex()
|
||||
displayText: root.displayLabel()
|
||||
leftPadding: 10
|
||||
rightPadding: 28
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
hoverEnabled: true
|
||||
activeFocusOnTab: true
|
||||
focusPolicy: Qt.StrongFocus
|
||||
Accessible.name: root.accessibleName
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: accountCombo.leftPadding
|
||||
rightPadding: accountCombo.rightPadding
|
||||
text: accountCombo.displayText
|
||||
color: accountCombo.enabled ? root.textColor : root.secondaryTextColor
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
indicator: Text {
|
||||
x: accountCombo.width - width - 10
|
||||
y: Math.round((accountCombo.height - height) / 2)
|
||||
text: "\u25BE"
|
||||
color: accountCombo.enabled ? root.secondaryTextColor : root.borderColor
|
||||
font.pixelSize: 10
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 7
|
||||
color: !accountCombo.enabled
|
||||
? root.backgroundColor
|
||||
: accountCombo.down || accountCombo.hovered
|
||||
? root.hoverColor : root.backgroundColor
|
||||
border.color: accountCombo.activeFocus ? root.focusColor : root.borderColor
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
delegate: ItemDelegate {
|
||||
id: optionDelegate
|
||||
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
width: ListView.view ? ListView.view.width : accountCombo.width
|
||||
height: 34
|
||||
hoverEnabled: true
|
||||
highlighted: accountCombo.highlightedIndex === optionDelegate.index
|
||||
|
||||
contentItem: Text {
|
||||
leftPadding: 8
|
||||
rightPadding: 8
|
||||
text: root.labelFor(optionDelegate.modelData)
|
||||
color: root.textColor
|
||||
font.pixelSize: 11
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 5
|
||||
color: optionDelegate.highlighted || optionDelegate.hovered
|
||||
? root.hoverColor : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
popup: Popup {
|
||||
y: accountCombo.height + 4
|
||||
width: accountCombo.width
|
||||
implicitHeight: Math.min(contentItem.implicitHeight + topPadding + bottomPadding,
|
||||
204)
|
||||
padding: 4
|
||||
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||
|
||||
contentItem: ListView {
|
||||
clip: true
|
||||
implicitHeight: contentHeight
|
||||
model: accountCombo.delegateModel
|
||||
currentIndex: accountCombo.highlightedIndex
|
||||
highlightMoveDuration: 0
|
||||
ScrollIndicator.vertical: ScrollIndicator { }
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
radius: 7
|
||||
color: root.backgroundColor
|
||||
border.color: root.borderColor
|
||||
border.width: 1
|
||||
}
|
||||
}
|
||||
|
||||
onActivated: function(index) {
|
||||
const choice = root.choices[index]
|
||||
if (!choice)
|
||||
return
|
||||
root.setSelection(choice.createNew === true
|
||||
? "" : root.accountIdFor(choice),
|
||||
choice.createNew === true)
|
||||
}
|
||||
}
|
||||
|
||||
function filteredAccounts() {
|
||||
root.modelRevision
|
||||
if (!root.criteriaReady)
|
||||
return []
|
||||
const result = []
|
||||
for (let index = 0; index < rows.count; ++index) {
|
||||
const object = rows.objectAt(index)
|
||||
const row = object ? root.valueFor(object, "accountRow") : null
|
||||
if (!row)
|
||||
continue
|
||||
const type = String(root.valueFor(row, "accountType")
|
||||
|| root.valueFor(row, "typeName")
|
||||
|| root.valueFor(row, "programType") || "")
|
||||
if (type !== root.accountType)
|
||||
continue
|
||||
if (root.stateField.length > 0
|
||||
&& root.scalarText(root.valueFor(row, root.stateField))
|
||||
!== root.scalarText(root.stateValue)) {
|
||||
continue
|
||||
}
|
||||
if (root.accountIdFor(row).length > 0)
|
||||
result.push(row)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function valueFor(row, field) {
|
||||
if (!row)
|
||||
return undefined
|
||||
if (row[field] !== undefined)
|
||||
return row[field]
|
||||
if (row.state && row.state[field] !== undefined)
|
||||
return row.state[field]
|
||||
if (row.fields && row.fields[field] !== undefined)
|
||||
return row.fields[field]
|
||||
return undefined
|
||||
}
|
||||
|
||||
function scalarText(value) {
|
||||
return value === undefined || value === null ? "" : String(value)
|
||||
}
|
||||
|
||||
function accountIdFor(row) {
|
||||
return String(root.valueFor(row, "accountId")
|
||||
|| root.valueFor(row, "displayAddress")
|
||||
|| root.valueFor(row, "address")
|
||||
|| root.valueFor(row, "holdingId") || "")
|
||||
}
|
||||
|
||||
function accountById(accountId) {
|
||||
const value = String(accountId || "")
|
||||
if (value.length === 0)
|
||||
return null
|
||||
for (let index = 0; index < root.matchingAccounts.length; ++index) {
|
||||
if (root.accountIdFor(root.matchingAccounts[index]) === value)
|
||||
return root.matchingAccounts[index]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function choiceRows() {
|
||||
const result = root.matchingAccounts.slice(0)
|
||||
if (root.selectionMode === ProgramAccountSelector.Output)
|
||||
result.push({ "createNew": true })
|
||||
return result
|
||||
}
|
||||
|
||||
function choiceIndex() {
|
||||
if (root.createNewSelected)
|
||||
return root.choices.length - 1
|
||||
for (let index = 0; index < root.choices.length; ++index) {
|
||||
if (root.accountIdFor(root.choices[index]) === root.selectedAccountId)
|
||||
return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function displayLabel() {
|
||||
const index = root.choiceIndex()
|
||||
if (index >= 0)
|
||||
return root.labelFor(root.choices[index])
|
||||
if (!root.criteriaReady)
|
||||
return root.criteriaPendingText
|
||||
return root.placeholderText
|
||||
}
|
||||
|
||||
function labelFor(row) {
|
||||
if (row && row.createNew === true)
|
||||
return root.createNewText
|
||||
const id = root.accountIdFor(row)
|
||||
const balanceValue = root.valueFor(row, "balanceRaw")
|
||||
const balance = balanceValue === undefined || balanceValue === null ? "" : String(balanceValue)
|
||||
return balance.length > 0
|
||||
? qsTr("%1 · %2").arg(root.shortId(id)).arg(balance)
|
||||
: root.shortId(id)
|
||||
}
|
||||
|
||||
function shortId(value) {
|
||||
const text = String(value || "")
|
||||
return text.length > 14 ? text.slice(0, 7) + "..." + text.slice(-5) : text
|
||||
}
|
||||
|
||||
function setSelection(accountId, createNew) {
|
||||
const nextId = String(accountId || "")
|
||||
const nextCreate = createNew === true
|
||||
if (root.selectedAccountId === nextId && root.createNewSelected === nextCreate)
|
||||
return
|
||||
root.selectedAccountId = nextId
|
||||
root.createNewSelected = nextCreate
|
||||
root.selectionChanged(nextId, nextCreate)
|
||||
}
|
||||
|
||||
function reconcileSelection() {
|
||||
if (!root.criteriaReady) {
|
||||
root.setSelection("", false)
|
||||
return
|
||||
}
|
||||
if (root.selectionValid)
|
||||
return
|
||||
if (root.selectionMode === ProgramAccountSelector.Input) {
|
||||
root.setSelection(root.matchingAccounts.length === 1
|
||||
? root.accountIdFor(root.matchingAccounts[0]) : "",
|
||||
false)
|
||||
return
|
||||
}
|
||||
if (root.createNewSelected)
|
||||
return
|
||||
if (root.matchingAccounts.length === 0) {
|
||||
root.setSelection("", true)
|
||||
} else if (root.matchingAccounts.length === 1) {
|
||||
root.setSelection(root.accountIdFor(root.matchingAccounts[0]), false)
|
||||
} else {
|
||||
root.setSelection("", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import QtQuick
|
||||
import QtTest
|
||||
import Logos.Wallet as Wallet
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
width: 480
|
||||
height: 320
|
||||
|
||||
readonly property var holdingA: ({
|
||||
"accountId": "holding-a",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": "token-a",
|
||||
"balanceRaw": "120"
|
||||
})
|
||||
readonly property var holdingB: ({
|
||||
"accountId": "holding-b",
|
||||
"accountType": "TokenHolding",
|
||||
"state": {
|
||||
"definitionId": "token-a",
|
||||
"balanceRaw": "80"
|
||||
}
|
||||
})
|
||||
readonly property var otherToken: ({
|
||||
"accountId": "holding-c",
|
||||
"accountType": "TokenHolding",
|
||||
"definitionId": "token-b",
|
||||
"balanceRaw": "50"
|
||||
})
|
||||
readonly property var otherType: ({
|
||||
"accountId": "pool-a",
|
||||
"accountType": "Pool",
|
||||
"definitionId": "token-a"
|
||||
})
|
||||
|
||||
Component {
|
||||
id: selectorComponent
|
||||
|
||||
Wallet.ProgramAccountSelector {
|
||||
width: 260
|
||||
accountType: "TokenHolding"
|
||||
stateField: "definitionId"
|
||||
stateValue: "token-a"
|
||||
}
|
||||
}
|
||||
|
||||
TestCase {
|
||||
name: "ProgramAccountSelector"
|
||||
when: windowShown
|
||||
|
||||
function test_inputNoFunds() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.otherToken, root.otherType],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "showEmptyInput", true)
|
||||
compare(selector.showCombo, false)
|
||||
compare(selector.hasFunds, false)
|
||||
compare(selector.ready, false)
|
||||
compare(selector.selectedAccountId, "")
|
||||
}
|
||||
|
||||
function test_inputSingleHoldingAutoSelectsWithoutCombo() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA, root.otherToken],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "selectedAccountId", "holding-a")
|
||||
compare(selector.showCombo, false)
|
||||
compare(selector.hasFunds, true)
|
||||
compare(selector.ready, true)
|
||||
compare(selector.selectedBalanceRaw, "120")
|
||||
}
|
||||
|
||||
function test_inputMultipleHoldingsRequiresSelection() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA, root.holdingB],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "showCombo", true)
|
||||
compare(selector.matchingAccounts.length, 2)
|
||||
compare(selector.ready, false)
|
||||
|
||||
selector.setSelection("holding-b", false)
|
||||
compare(selector.selectedAccountId, "holding-b")
|
||||
compare(selector.selectedBalanceRaw, "80")
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
|
||||
function test_outputNoHoldingSelectsCreateNew() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Output
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "createNewSelected", true)
|
||||
compare(selector.showCombo, true)
|
||||
compare(selector.choices.length, 1)
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
|
||||
function test_outputSingleHoldingOffersExistingAndCreateNew() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Output
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "selectedAccountId", "holding-a")
|
||||
compare(selector.choices.length, 2)
|
||||
compare(selector.createNewSelected, false)
|
||||
compare(selector.ready, true)
|
||||
|
||||
selector.setSelection("", true)
|
||||
compare(selector.selectedAccountId, "")
|
||||
compare(selector.createNewSelected, true)
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
|
||||
function test_outputMultipleHoldingsRequiresDestination() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [root.holdingA, root.holdingB],
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Output
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "showCombo", true)
|
||||
compare(selector.choices.length, 3)
|
||||
compare(selector.selectedAccountId, "")
|
||||
compare(selector.createNewSelected, false)
|
||||
compare(selector.ready, false)
|
||||
}
|
||||
|
||||
function test_matchesNumericZeroState() {
|
||||
const selector = createTemporaryObject(selectorComponent, root, {
|
||||
"sourceModel": [{
|
||||
"accountId": "zero-state",
|
||||
"accountType": "TokenHolding",
|
||||
"state": { "version": 0 }
|
||||
}],
|
||||
"stateField": "version",
|
||||
"stateValue": 0,
|
||||
"selectionMode": Wallet.ProgramAccountSelector.Input
|
||||
})
|
||||
verify(!!selector, "Component exists")
|
||||
tryCompare(selector, "selectedAccountId", "zero-state")
|
||||
compare(selector.ready, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user