Updated on 2026-08-14
This commit is contained in:
parent
a6e7008772
commit
5fce0646d8
6 changed files with 99 additions and 24 deletions
|
|
@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -25,6 +26,8 @@ import androidx.compose.ui.layout.layoutId
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
@ -201,9 +204,15 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
is ContentSubtitle.PlainAddress -> PlainAddressText(
|
||||||
|
subtitle = subtitle,
|
||||||
|
status = status,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
is ContentSubtitle.ExternalAddress -> InlineImageSubtitle(
|
is ContentSubtitle.ExternalAddress -> InlineImageSubtitle(
|
||||||
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.briefAddress),
|
template = stringResourceSafe(subtitle.direction.templateResId(), subtitle.briefAddress),
|
||||||
color = tertiary,
|
color = tertiary,
|
||||||
|
afterIconColor = if (isFailed) tertiary else primary,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
IdentIcon(
|
IdentIcon(
|
||||||
|
|
@ -256,6 +265,33 @@ private fun SubtitleText(subtitle: ContentSubtitle, status: Status, modifier: Mo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlainAddressText(subtitle: ContentSubtitle.PlainAddress, status: Status, modifier: Modifier = Modifier) {
|
||||||
|
val full = subtitle.text.resolveReference()
|
||||||
|
val highlightColor = if (status is Status.Failed) {
|
||||||
|
TangemTheme.colors2.text.neutral.tertiary
|
||||||
|
} else {
|
||||||
|
TangemTheme.colors2.text.neutral.primary
|
||||||
|
}
|
||||||
|
val text = remember(full, subtitle.highlight, highlightColor) {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(full)
|
||||||
|
val start = full.lastIndexOf(subtitle.highlight)
|
||||||
|
if (start >= 0) {
|
||||||
|
addStyle(SpanStyle(color = highlightColor), start, start + subtitle.highlight.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = TangemTheme.colors2.text.neutral.tertiary,
|
||||||
|
style = TangemTheme.typography2.captionMedium12,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ContentSubtitle.Direction.templateResId(): Int = when (this) {
|
private fun ContentSubtitle.Direction.templateResId(): Int = when (this) {
|
||||||
ContentSubtitle.Direction.TO -> R.string.transaction_history_to_inline_address
|
ContentSubtitle.Direction.TO -> R.string.transaction_history_to_inline_address
|
||||||
ContentSubtitle.Direction.FROM -> R.string.transaction_history_from_inline_address
|
ContentSubtitle.Direction.FROM -> R.string.transaction_history_from_inline_address
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,16 @@ sealed interface TransactionItemUM {
|
||||||
/** Subtitle variants for [Content] rows. */
|
/** Subtitle variants for [Content] rows. */
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface ContentSubtitle {
|
sealed interface ContentSubtitle {
|
||||||
/** Plain text — for types without a directly-displayable address (Operation, GaslessFee, ClaimRewards, etc.). */
|
/** Plain text — for types without a displayable address (Operation, GaslessFee, ClaimRewards, etc.). */
|
||||||
data class Plain(val text: TextReference) : ContentSubtitle
|
data class Plain(val text: TextReference) : ContentSubtitle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain-text address subtitle without an identicon — renders "prefix: <address>", where [highlight]
|
||||||
|
* (a substring of the resolved [text]) is painted in the primary text color while the prefix stays
|
||||||
|
* tertiary. Used for contract / validator addresses, external swap addresses and yield "for: <address>".
|
||||||
|
*/
|
||||||
|
data class PlainAddress(val text: TextReference, val highlight: String) : ContentSubtitle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* External counterparty address — renders as "to/from: <identicon> <briefAddress>".
|
* External counterparty address — renders as "to/from: <identicon> <briefAddress>".
|
||||||
* Used for Transfer to/from external addresses.
|
* Used for Transfer to/from external addresses.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package com.tangem.feature.swap.model
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
|
||||||
|
import com.tangem.core.ui.extensions.TextReference
|
||||||
import com.tangem.domain.models.currency.CryptoCurrency
|
import com.tangem.domain.models.currency.CryptoCurrency
|
||||||
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
import com.tangem.domain.models.currency.CryptoCurrencyStatus
|
||||||
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
|
||||||
|
|
@ -41,7 +42,7 @@ internal class SwapModelGetSelectApprovalTypeParamsTest : SwapModelTestBase() {
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN provider state is not QuotesLoadedState THEN returns null`() {
|
fun `GIVEN provider state is not QuotesLoadedState THEN returns null`() {
|
||||||
val provider = swapProvider()
|
val provider = swapProvider()
|
||||||
val notLoaded: SwapState.EmptyAmountState = mockk(relaxed = true)
|
val notLoaded = SwapState.EmptyAmountState(zeroAmountEquivalent = TextReference.EMPTY)
|
||||||
val model = createModel()
|
val model = createModel()
|
||||||
model.dataState = model.dataState.copy(
|
model.dataState = model.dataState.copy(
|
||||||
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
fromSwapCurrencyStatus = swapCurrencyStatus(),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.tangem.features.txhistory.impl"
|
namespace = "com.tangem.features.txhistory.impl"
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
merges += "paymentrequest.proto"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
/* Project - API */
|
/* Project - API */
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = stringReference(type.name),
|
title = stringReference(type.name),
|
||||||
iconRes = tx.directionalIcon(),
|
iconRes = tx.directionalIcon(),
|
||||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
subtitle = tx.extractAddressSubtitle(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
private fun swapContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
||||||
|
|
@ -92,7 +92,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
|
title = tx.statusAwareTitle(R.string.common_swapping, R.string.common_swapped),
|
||||||
iconRes = tx.directionalIcon(),
|
iconRes = tx.directionalIcon(),
|
||||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
subtitle = tx.extractAddressSubtitle(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content {
|
private fun transferContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content {
|
||||||
|
|
@ -112,7 +112,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
rawAddress = counterpartyAddress,
|
rawAddress = counterpartyAddress,
|
||||||
briefAddress = counterpartyAddress.toBriefAddressFormat(),
|
briefAddress = counterpartyAddress.toBriefAddressFormat(),
|
||||||
)
|
)
|
||||||
else -> ContentSubtitle.Plain(tx.extractSubtitleByAddressType())
|
else -> tx.extractAddressSubtitle()
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildContent(
|
return buildContent(
|
||||||
|
|
@ -145,7 +145,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = resourceReference(R.string.yield_module_transaction_topup),
|
title = resourceReference(R.string.yield_module_transaction_topup),
|
||||||
iconRes = tx.directionalIcon(),
|
iconRes = tx.directionalIcon(),
|
||||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
subtitle = tx.yieldSupplySubtitle(currency, type),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun yieldDeployContractContent(
|
private fun yieldDeployContractContent(
|
||||||
|
|
@ -157,7 +157,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = resourceReference(R.string.yield_module_transaction_deploy_contract),
|
title = resourceReference(R.string.yield_module_transaction_deploy_contract),
|
||||||
iconRes = R.drawable.ic_doc_24,
|
iconRes = R.drawable.ic_doc_24,
|
||||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
subtitle = tx.yieldSupplySubtitle(currency, type),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun yieldInitializeTokenContent(
|
private fun yieldInitializeTokenContent(
|
||||||
|
|
@ -169,7 +169,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = resourceReference(R.string.yield_module_transaction_initialize),
|
title = resourceReference(R.string.yield_module_transaction_initialize),
|
||||||
iconRes = R.drawable.ic_gear_24,
|
iconRes = R.drawable.ic_gear_24,
|
||||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
subtitle = tx.yieldSupplySubtitle(currency, type),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun yieldReactivateTokenContent(
|
private fun yieldReactivateTokenContent(
|
||||||
|
|
@ -181,7 +181,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = resourceReference(R.string.yield_module_transaction_reactivate),
|
title = resourceReference(R.string.yield_module_transaction_reactivate),
|
||||||
iconRes = R.drawable.ic_refresh_24,
|
iconRes = R.drawable.ic_refresh_24,
|
||||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
subtitle = tx.yieldSupplySubtitle(currency, type),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun yieldSendContent(
|
private fun yieldSendContent(
|
||||||
|
|
@ -197,7 +197,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
resourceReference(R.string.common_transfer)
|
resourceReference(R.string.common_transfer)
|
||||||
},
|
},
|
||||||
iconRes = tx.directionalIcon(),
|
iconRes = tx.directionalIcon(),
|
||||||
subtitle = ContentSubtitle.Plain(tx.yieldSupplySubtitle(currency, type)),
|
subtitle = tx.yieldSupplySubtitle(currency, type),
|
||||||
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
|
hideAmount = currency is CryptoCurrency.Token && !tx.isOutgoing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -209,7 +209,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = resourceReference(R.string.transaction_history_operation),
|
title = resourceReference(R.string.transaction_history_operation),
|
||||||
iconRes = tx.directionalIcon(),
|
iconRes = tx.directionalIcon(),
|
||||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
subtitle = tx.extractAddressSubtitle(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun gaslessFeeContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
private fun gaslessFeeContent(tx: TxInfo, uiStatus: TransactionItemUM.Content.Status): TransactionItemUM.Content =
|
||||||
|
|
@ -218,7 +218,7 @@ internal class TxHistoryItemToTransactionItemUMConverter(
|
||||||
uiStatus = uiStatus,
|
uiStatus = uiStatus,
|
||||||
title = resourceReference(R.string.gasless_transaction_fee),
|
title = resourceReference(R.string.gasless_transaction_fee),
|
||||||
iconRes = tx.directionalIcon(),
|
iconRes = tx.directionalIcon(),
|
||||||
subtitle = ContentSubtitle.Plain(tx.extractSubtitleByAddressType()),
|
subtitle = tx.extractAddressSubtitle(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun buildContent(
|
private fun buildContent(
|
||||||
|
|
@ -283,15 +283,21 @@ private fun resolveOwnSubtitle(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: TransactionType.YieldSupply): TextReference {
|
private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: TransactionType.YieldSupply): ContentSubtitle {
|
||||||
if (currency is CryptoCurrency.Coin) {
|
if (currency is CryptoCurrency.Coin) {
|
||||||
return if (type is TransactionType.YieldSupply.Send) {
|
return if (type is TransactionType.YieldSupply.Send) {
|
||||||
extractSubtitleByAddressType()
|
extractAddressSubtitle()
|
||||||
} else {
|
} else {
|
||||||
resourceReference(
|
val briefAddress = type.address?.toBriefAddressFormat()
|
||||||
|
val text = resourceReference(
|
||||||
R.string.transaction_history_transaction_for_address,
|
R.string.transaction_history_transaction_for_address,
|
||||||
wrappedList(type.address?.toBriefAddressFormat().orEmpty()),
|
wrappedList(briefAddress.orEmpty()),
|
||||||
)
|
)
|
||||||
|
if (briefAddress.isNullOrEmpty()) {
|
||||||
|
ContentSubtitle.Plain(text)
|
||||||
|
} else {
|
||||||
|
ContentSubtitle.PlainAddress(text = text, highlight = briefAddress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return when (type) {
|
return when (type) {
|
||||||
|
|
@ -304,15 +310,34 @@ private fun TxInfo.yieldSupplySubtitle(currency: CryptoCurrency, type: Transacti
|
||||||
is TransactionType.YieldSupply.Send -> if (!isOutgoing && type.isYieldSupplyWithdraw) {
|
is TransactionType.YieldSupply.Send -> if (!isOutgoing && type.isYieldSupplyWithdraw) {
|
||||||
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
|
amountSubtitle(currency, R.string.yield_module_transaction_exit_subtitle)
|
||||||
} else {
|
} else {
|
||||||
extractSubtitleByAddressType()
|
extractAddressSubtitle()
|
||||||
}
|
}
|
||||||
else -> extractSubtitleByAddressType()
|
else -> extractAddressSubtitle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TxInfo.amountSubtitle(currency: CryptoCurrency, @StringRes resId: Int): TextReference {
|
private fun TxInfo.amountSubtitle(currency: CryptoCurrency, @StringRes resId: Int): ContentSubtitle.Plain {
|
||||||
val formatted = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
|
val formatted = amount.format { crypto(symbol = currency.symbol, decimals = currency.decimals) }
|
||||||
return resourceReference(resId, wrappedList(formatted))
|
return ContentSubtitle.Plain(resourceReference(resId, wrappedList(formatted)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TxInfo.extractAddressSubtitle(): ContentSubtitle {
|
||||||
|
val text = extractSubtitleByAddressType()
|
||||||
|
val highlight = interactionAddressType.highlightableBriefAddress()
|
||||||
|
return if (highlight == null) {
|
||||||
|
ContentSubtitle.Plain(text)
|
||||||
|
} else {
|
||||||
|
ContentSubtitle.PlainAddress(text = text, highlight = highlight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TxInfo.InteractionAddressType?.highlightableBriefAddress(): String? = when (this) {
|
||||||
|
is TxInfo.InteractionAddressType.Contract -> address.toBriefAddressFormat()
|
||||||
|
is TxInfo.InteractionAddressType.User -> address.toBriefAddressFormat()
|
||||||
|
is TxInfo.InteractionAddressType.Validator -> address.toBriefAddressFormat()
|
||||||
|
is TxInfo.InteractionAddressType.Multiple,
|
||||||
|
null,
|
||||||
|
-> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TxInfo.extractSubtitleByAddressType(): TextReference =
|
private fun TxInfo.extractSubtitleByAddressType(): TextReference =
|
||||||
|
|
|
||||||
|
|
@ -341,7 +341,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `GIVEN Transfer with non-User interaction WHEN convert THEN Plain subtitle`() {
|
fun `GIVEN Transfer with non-User interaction WHEN convert THEN PlainAddress subtitle`() {
|
||||||
val tx = txInfo(
|
val tx = txInfo(
|
||||||
type = TransactionType.Transfer,
|
type = TransactionType.Transfer,
|
||||||
isOutgoing = true,
|
isOutgoing = true,
|
||||||
|
|
@ -350,7 +350,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
|
||||||
|
|
||||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||||
|
|
||||||
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.Plain::class.java)
|
assertThat(result.subtitle).isInstanceOf(ContentSubtitle.PlainAddress::class.java)
|
||||||
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
|
assertThat(result.title).isEqualTo(resRef(R.string.common_sent))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -575,7 +575,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
|
||||||
|
|
||||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||||
|
|
||||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
val subtitle = result.subtitle as ContentSubtitle.PlainAddress
|
||||||
val res = subtitle.text as TextReference.Res
|
val res = subtitle.text as TextReference.Res
|
||||||
assertThat(res.id).isEqualTo(R.string.transaction_history_contract_address)
|
assertThat(res.id).isEqualTo(R.string.transaction_history_contract_address)
|
||||||
}
|
}
|
||||||
|
|
@ -623,7 +623,7 @@ internal class TxHistoryItemToTransactionItemUMConverterTest {
|
||||||
|
|
||||||
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
val result = coinConverter.convert(tx) as TransactionItemUM.Content
|
||||||
|
|
||||||
val subtitle = result.subtitle as ContentSubtitle.Plain
|
val subtitle = result.subtitle as ContentSubtitle.PlainAddress
|
||||||
val res = subtitle.text as TextReference.Res
|
val res = subtitle.text as TextReference.Res
|
||||||
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_validator)
|
assertThat(res.id).isEqualTo(R.string.transaction_history_transaction_validator)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue