Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
🤖 [Dynamic Cards] Ordering logic
Order type `top` cards will be sorted to the beginning of the card list.
Order type `bottom` cards will be sorted to the end of the card list.


Discussion hints:
nan

Pull request: 🤖 [Dynamic Cards] Ordering logic
Fixes #19739

## Description
This PR builds the UI models in the right order/position from the API response.

Note: I added a separate issue for the remote feature flag display logic to avoid extending this PR more https://github.com/wordpress-mobile/WordPress-Android/issues/19797

-----

## To Test:
- Apply this patch: [cardmockspatch.patch](https://github.com/wordpress-mobile/WordPress-Android/files/13678298/cardmockspatch.patch)
- Run the app and confirm that you can see two dynamic cards on the top and bottom of the dashboard.
### Alternatively
- Add a break point [here](https://github.com/wordpress-mobile/WordPress-Android/blob/trunk/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/CardsBuilder.kt#L37)
- Run and verify that the expected `Dynamic` objects are listed

-----

## Regression Notes

1. Potential unintended areas of impact

    - My site dashboard

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - Manual testing

3. What automated tests I added (or what prevented me from doing so)

    - Added unit tests and adapted existing

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## UI Changes Testing Checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)

Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilderTest" --tests "org.wordpress.android.ui.mysite.MySiteViewModelTest" --tests "org.wordpress.android.ui.mysite.cards.dashboard.CardsBuilderTest" --tests "org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsViewModelSliceTest" --tests "org.wordpress.android.ui.mysite.cards.CardsBuilderTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt
index 207d40c6055b..6f4e178089c9 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt
@@ -8,6 +8,7 @@ import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.BloganuaryNudgeCar
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.BloggingPromptCard.BloggingPromptCardWithData
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.DashboardPlansCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.DomainRegistrationCard
+import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.Dynamic
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.ErrorCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.ErrorWithinCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.JetpackFeatureCard
@@ -66,6 +67,9 @@ object MySiteAdapterDiffCallback : DiffUtil.ItemCallback<MySiteCardAndItem>() {
                     MySiteCardAndItem.Card.NoCardsMessage -> true
             oldItem is PersonalizeCardModel && updatedItem is PersonalizeCardModel -> true
             oldItem is WpSotw2023NudgeCardModel && updatedItem is WpSotw2023NudgeCardModel -> true
+            oldItem is Dynamic && updatedItem is Dynamic -> {
+                oldItem.id == updatedItem.id
+            }
             else -> throw UnsupportedOperationException("Diff not implemented yet")
         }
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt
index 0b78ee06dee8..ec761509ef6c 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt
@@ -6,6 +6,7 @@ import org.wordpress.android.fluxc.model.SiteModel
 import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel
 import org.wordpress.android.fluxc.model.bloggingprompts.BloggingPromptModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel
@@ -68,8 +69,9 @@ sealed class MySiteCardAndItemBuilderParams {
         val blazeCardBuilderParams: BlazeCardBuilderParams? = null,
         val dashboardCardPlansBuilderParams: DashboardCardPlansBuilderParams,
         val pagesCardBuilderParams: PagesCardBuilderParams,
-        val activityCardBuilderParams: ActivityCardBuilderParams
-    ) : MySiteCardAndItemBuilderParams()
+        val activityCardBuilderParams: ActivityCardBuilderParams,
+        val dynamicCardsBuilderParams: DynamicCardsBuilderParams,
+        ) : MySiteCardAndItemBuilderParams()
 
     data class TodaysStatsCardBuilderParams(
         val todaysStatsCard: TodaysStatsCardModel?,
@@ -158,6 +160,13 @@ sealed class MySiteCardAndItemBuilderParams {
         val onHideMenuItemClick: () -> Unit
     ) : MySiteCardAndItemBuilderParams()
 
+    data class DynamicCardsBuilderParams(
+        val dynamicCards: DynamicCardsModel?,
+        val onActionClick: (actionUrl: String) -> Unit,
+        val onMoreMenuClick: (cardId: String) -> Unit,
+        val onHideMenuItemClick: (cardId: String) -> Unit
+    ) : MySiteCardAndItemBuilderParams()
+
     sealed class BlazeCardBuilderParams : MySiteCardAndItemBuilderParams() {
         data class PromoteWithBlazeCardBuilderParams(
             val onClick: () -> Unit,
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt
index c498ecca501c..68af2a267ddf 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt
@@ -20,6 +20,7 @@ import org.wordpress.android.R
 import org.wordpress.android.analytics.AnalyticsTracker.Stat
 import org.wordpress.android.fluxc.Dispatcher
 import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.ActivityCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel
@@ -68,6 +69,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.pages.PagesCardViewModelS
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardUtils
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostsCardViewModelSlice
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsViewModelSlice
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsViewModelSlice
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardHelper
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardShownTracker
 import org.wordpress.android.ui.mysite.cards.jpfullplugininstall.JetpackInstallFullPluginCardBuilder
@@ -155,6 +157,7 @@ class MySiteViewModel @Inject constructor(
     private val wpJetpackIndividualPluginHelper: WPJetpackIndividualPluginHelper,
     private val blazeCardViewModelSlice: BlazeCardViewModelSlice,
     private val pagesCardViewModelSlice: PagesCardViewModelSlice,
+    private val dynamicCardsViewModelSlice: DynamicCardsViewModelSlice,
     private val todaysStatsViewModelSlice: TodaysStatsViewModelSlice,
     private val postsCardViewModelSlice: PostsCardViewModelSlice,
     private val activityLogCardViewModelSlice: ActivityLogCardViewModelSlice,
@@ -497,6 +500,9 @@ class MySiteViewModel @Inject constructor(
                 activityCardBuilderParams = activityLogCardViewModelSlice.getActivityLogCardBuilderParams(
                     cardsUpdate?.cards?.firstOrNull { it is ActivityCardModel } as? ActivityCardModel
                 ),
+                dynamicCardsBuilderParams = dynamicCardsViewModelSlice.getBuilderParams(
+                    cardsUpdate?.cards?.firstOrNull { it is DynamicCardsModel } as? DynamicCardsModel
+                )
             ),
             jetpackInstallFullPluginCardParams
         )
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt
index 98fa711083d4..9cb9e0d59c5e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt
@@ -1,5 +1,6 @@
 package org.wordpress.android.ui.mysite.cards.dashboard
 
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
 import org.wordpress.android.ui.mysite.MySiteCardAndItem
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DashboardCardsBuilderParams
 import org.wordpress.android.ui.mysite.cards.blaze.BlazeCardBuilder
@@ -10,6 +11,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.pages.PagesCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardBuilder
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilder
 import org.wordpress.android.ui.utils.ListItemInteraction
 import javax.inject.Inject
 
@@ -21,7 +23,8 @@ class CardsBuilder @Inject constructor(
     private val blazeCardBuilder: BlazeCardBuilder,
     private val plansCardBuilder: PlansCardBuilder,
     private val pagesCardBuilder: PagesCardBuilder,
-    private val activityCardBuilder: ActivityCardBuilder
+    private val activityCardBuilder: ActivityCardBuilder,
+    private val dynamicCardsBuilder: DynamicCardsBuilder,
 ) {
     fun build(
         dashboardCardsBuilderParams: DashboardCardsBuilderParams
@@ -29,6 +32,9 @@ class CardsBuilder @Inject constructor(
         if (dashboardCardsBuilderParams.showErrorCard) {
             add(createErrorCard(dashboardCardsBuilderParams.onErrorRetryClick))
         } else {
+            dynamicCardsBuilder.build(dashboardCardsBuilderParams.dynamicCardsBuilderParams, CardOrder.TOP)
+                ?.let { addAll(it) }
+
             bloganuaryNudgeCardBuilder.build(dashboardCardsBuilderParams.bloganuaryNudgeCardBuilderParams)
                 ?.let { add(it) }
 
@@ -51,6 +57,9 @@ class CardsBuilder @Inject constructor(
             pagesCardBuilder.build(dashboardCardsBuilderParams.pagesCardBuilderParams)?.let { add(it) }
 
             activityCardBuilder.build(dashboardCardsBuilderParams.activityCardBuilderParams)?.let { add(it) }
+
+            dynamicCardsBuilder.build(dashboardCardsBuilderParams.dynamicCardsBuilderParams, CardOrder.BOTTOM)
+                ?.let { addAll(it) }
         }
     }.toList()
 
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
new file mode 100644
index 000000000000..23712cf6bbd0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
@@ -0,0 +1,73 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
+import org.wordpress.android.ui.deeplinks.handlers.DeepLinkHandlers
+import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.Dynamic
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams
+import org.wordpress.android.ui.utils.ListItemInteraction.Companion.create
+import org.wordpress.android.util.UriWrapper
+import org.wordpress.android.util.UrlUtilsWrapper
+import javax.inject.Inject
+
+class DynamicCardsBuilder @Inject constructor(
+    private val urlUtils: UrlUtilsWrapper,
+    private val deepLinkHandlers: DeepLinkHandlers
+) {
+    fun build(params: DynamicCardsBuilderParams, order: CardOrder): List<Dynamic>? {
+        if (!shouldBuildCard(params, order)) {
+            return null
+        }
+        return convertToDynamicCards(params, order)
+    }
+
+    private fun shouldBuildCard(params: DynamicCardsBuilderParams, order: CardOrder): Boolean {
+        if (params.dynamicCards == null || params.dynamicCards.pages.none { it.order == order }) return false
+        return true
+    }
+
+    private fun convertToDynamicCards(params: DynamicCardsBuilderParams, order: CardOrder): List<Dynamic> {
+        val cards = params.dynamicCards?.pages?.filter { it.order == order } ?: emptyList()
+        return cards.map { card ->
+            Dynamic(
+                id = card.id,
+                rows = card.rows.map { row ->
+                    Dynamic.Row(
+                        iconUrl = row.icon,
+                        title = row.title,
+                        description = row.description,
+                    )
+                },
+                title = card.title,
+                image = card.featuredImage,
+                action = getActionSource(params, card),
+                onHideMenuItemClick = create(card.id, params.onHideMenuItemClick),
+            )
+        }
+    }
+
+    private fun getActionSource(
+        params: DynamicCardsBuilderParams,
+        card: CardModel.DynamicCardsModel.DynamicCardModel
+    ): Dynamic.ActionSource? = when {
+        isValidUrlOrDeeplink(card.url) && isValidActionTitle(card.action) -> Dynamic.ActionSource.Button(
+            requireNotNull(card.url),
+            create(requireNotNull(card.url), params.onActionClick),
+            requireNotNull(card.action)
+        )
+        isValidUrlOrDeeplink(card.url) -> Dynamic.ActionSource.Card(
+            requireNotNull(card.url),
+            create(requireNotNull(card.url), params.onActionClick)
+        )
+        else -> null
+    }
+
+    private fun isValidUrlOrDeeplink(url: String?): Boolean {
+        return !url.isNullOrEmpty() && (urlUtils.isValidUrlAndHostNotNull(url)
+                || deepLinkHandlers.buildNavigateAction(UriWrapper(url)) != null)
+    }
+
+    private fun isValidActionTitle(title: String?): Boolean {
+        return !title.isNullOrEmpty()
+    }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSlice.kt
new file mode 100644
index 000000000000..8def20febd51
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSlice.kt
@@ -0,0 +1,41 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams
+import org.wordpress.android.ui.mysite.SiteNavigationAction
+import org.wordpress.android.util.AppLog
+import org.wordpress.android.viewmodel.Event
+import javax.inject.Inject
+
+class DynamicCardsViewModelSlice @Inject constructor() {
+    private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
+    val onNavigation = _onNavigation as LiveData<Event<SiteNavigationAction>>
+
+    private val _refresh = MutableLiveData<Event<Boolean>>()
+    val refresh = _refresh as LiveData<Event<Boolean>>
+    fun getBuilderParams(dynamicCards: CardModel.DynamicCardsModel?): DynamicCardsBuilderParams {
+        return DynamicCardsBuilderParams(
+            dynamicCards = dynamicCards,
+            onActionClick = this::onActionClick,
+            onMoreMenuClick = this::onMoreMenuClick,
+            onHideMenuItemClick = this::onHideMenuItemClick
+        )
+    }
+
+    private fun onActionClick(actionUrl: String) {
+        AppLog.d(AppLog.T.MY_SITE_DASHBOARD, "Dynamic dashboard card action clicked: $actionUrl")
+        // TODO
+    }
+
+    private fun onMoreMenuClick(cardId: String) {
+        AppLog.d(AppLog.T.MY_SITE_DASHBOARD, "Dynamic dashboard card action more menu click: $cardId")
+        // TODO
+    }
+
+    private fun onHideMenuItemClick(cardId: String) {
+        AppLog.d(AppLog.T.MY_SITE_DASHBOARD, "Dynamic dashboard card action hide menu click: $cardId")
+        // TODO
+    }
+}


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt
index 1610b066b8f0..4ebeb51b285e 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt
@@ -83,6 +83,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.pages.PagesCardViewModelS
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardUtils
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostsCardViewModelSlice
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsViewModelSlice
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsViewModelSlice
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardHelper
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardShownTracker
 import org.wordpress.android.ui.mysite.cards.jpfullplugininstall.JetpackInstallFullPluginCardBuilder
@@ -282,6 +283,10 @@ class MySiteViewModelTest : BaseUnitTest() {
     @Mock
     lateinit var wpSotw2023NudgeCardViewModelSlice: WpSotw2023NudgeCardViewModelSlice
 
+    @Mock
+    lateinit var dynamicCardsViewModelSlice: DynamicCardsViewModelSlice
+
+
     private lateinit var viewModel: MySiteViewModel
     private lateinit var uiModels: MutableList<MySiteViewModel.State>
     private lateinit var snackbars: MutableList<SnackbarMessageHolder>
@@ -416,6 +421,14 @@ class MySiteViewModelTest : BaseUnitTest() {
         whenever(activityLogCardViewModelSlice.getActivityLogCardBuilderParams(anyOrNull())).thenReturn(mock())
         whenever(personalizeCardViewModelSlice.getBuilderParams()).thenReturn(mock())
         whenever(personalizeCardBuilder.build(any())).thenReturn(mock())
+        whenever(dynamicCardsViewModelSlice.getBuilderParams(anyOrNull())).thenReturn(
+            MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+                mock(),
+                mock(),
+                mock(),
+                mock()
+            )
+        )
         whenever(bloganuaryNudgeViewModelSlice.getBuilderParams()).thenReturn(mock())
         whenever(bloggingPromptCardViewModelSlice.getBuilderParams(anyOrNull())).thenReturn(mock())
         whenever(quickLinksItemViewModelSlice.uiState).thenReturn(mock())
@@ -462,6 +475,7 @@ class MySiteViewModelTest : BaseUnitTest() {
             wpJetpackIndividualPluginHelper,
             blazeCardViewModelSlice,
             pagesCardViewModelSlice,
+            dynamicCardsViewModelSlice,
             todaysStatsViewModelSlice,
             postsCardViewModelSlice,
             activityLogCardViewModelSlice,
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt
index 304b3bf76228..2ad7f5068215 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt
@@ -16,6 +16,7 @@ import org.wordpress.android.ui.mysite.MySiteCardAndItem
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.DomainRegistrationCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.QuickStartCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.QuickStartCard.QuickStartTaskTypeItem
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.ActivityCardBuilderParams
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.BlazeCardBuilderParams
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.BloganuaryNudgeCardBuilderParams
@@ -156,7 +157,8 @@ class CardsBuilderTest {
                     mock()
                 ),
                 pagesCardBuilderParams = PagesCardBuilderParams(mock(), mock(), mock(), mock()),
-                activityCardBuilderParams = ActivityCardBuilderParams(mock(), mock(), mock(), mock(), mock())
+                activityCardBuilderParams = ActivityCardBuilderParams(mock(), mock(), mock(), mock(), mock()),
+                dynamicCardsBuilderParams = DynamicCardsBuilderParams(mock(), mock(), mock(), mock()),
             ),
             jetpackInstallFullPluginCardBuilderParams = JetpackInstallFullPluginCardBuilderParams(
                 site = site,
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt
index cb26f3fdf805..c97fd4f584e6 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt
@@ -40,6 +40,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostCardType.DRAFT
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardBuilder
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilder
 import org.wordpress.android.ui.utils.UiString.UiStringText
 
 @ExperimentalCoroutinesApi
@@ -69,6 +70,9 @@ class CardsBuilderTest : BaseUnitTest() {
     @Mock
     lateinit var bloganuaryCardBuilder: BloganuaryNudgeCardBuilder
 
+    @Mock
+    lateinit var dynamicCardsBuilder: DynamicCardsBuilder
+
     private lateinit var cardsBuilder: CardsBuilder
 
     @Before
@@ -81,7 +85,8 @@ class CardsBuilderTest : BaseUnitTest() {
             blazeCardBuilder,
             dashboardPlansCardBuilder,
             pagesCardBuilder,
-            activityCardBuilder
+            activityCardBuilder,
+            dynamicCardsBuilder
         )
     }
 
@@ -352,6 +357,12 @@ class CardsBuilderTest : BaseUnitTest() {
                     mock(),
                     mock(),
                     mock()
+                ),
+                dynamicCardsBuilderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+                    mock(),
+                    mock(),
+                    mock(),
+                    mock()
                 )
             )
         )
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
new file mode 100644
index 000000000000..dfe4f19c3703
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
@@ -0,0 +1,212 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.ui.deeplinks.handlers.DeepLinkHandlers
+import org.wordpress.android.ui.mysite.MySiteCardAndItem
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams
+import org.wordpress.android.util.UrlUtilsWrapper
+import kotlin.test.assertEquals
+
+/* DYNAMIC CARDS */
+private const val DYNAMIC_CARD_ID = "year_in_review_2023"
+private const val DYNAMIC_CARD_TITLE = "News"
+private const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
+private const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
+private const val DYNAMIC_CARD_URL = "https://wordpress.com"
+private const val DYNAMIC_CARD_ACTION = "Call to action"
+private const val DYNAMIC_CARD_ORDER = "top"
+private const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
+private const val DYNAMIC_CARD_ROW_TITLE = "Row title"
+private const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
+
+private val DYNAMIC_CARD_ROW_MODEL = CardModel.DynamicCardsModel.DynamicCardRowModel(
+    icon = DYNAMIC_CARD_ROW_ICON,
+    title = DYNAMIC_CARD_ROW_TITLE,
+    description = DYNAMIC_CARD_ROW_DESCRIPTION
+)
+
+private val DYNAMIC_CARD_MODEL = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = null,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = "",
+    action = null,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_INVALID_URL = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = null,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+@ExperimentalCoroutinesApi
+class DynamicCardsBuilderTest : BaseUnitTest() {
+    private lateinit var dynamicCardsBuilder: DynamicCardsBuilder
+
+    @Mock
+    private lateinit var urlUtils: UrlUtilsWrapper
+
+    @Mock
+    private lateinit var deepLinkHandlers: DeepLinkHandlers
+
+    @Before
+    fun setUp() {
+        dynamicCardsBuilder = DynamicCardsBuilder(urlUtils, deepLinkHandlers)
+        whenever(urlUtils.isValidUrlAndHostNotNull(DYNAMIC_CARD_URL)).thenReturn(true)
+    }
+
+    @Test
+    fun testBuild() {
+        val expectedCards = listOf(
+            MySiteCardAndItem.Card.Dynamic(
+                id = DYNAMIC_CARD_ID,
+                rows = listOf(
+                    MySiteCardAndItem.Card.Dynamic.Row(
+                        iconUrl = DYNAMIC_CARD_ROW_ICON,
+                        title = DYNAMIC_CARD_ROW_TITLE,
+                        description = DYNAMIC_CARD_ROW_DESCRIPTION,
+                    )
+                ),
+                title = DYNAMIC_CARD_TITLE,
+                image = DYNAMIC_CARD_FEATURED_IMAGE,
+                action = MySiteCardAndItem.Card.Dynamic.ActionSource.Button(
+                    url = DYNAMIC_CARD_URL,
+                    onCtaClick = mock(),
+                    title = DYNAMIC_CARD_ACTION
+                ),
+                onHideMenuItemClick = mock(),
+            )
+        )
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertEquals(expectedCards[0].id, dynamicCards[0].id)
+        assertEquals(expectedCards[0].title, dynamicCards[0].title)
+        assertEquals(expectedCards[0].image, dynamicCards[0].image)
+        assertThat(expectedCards[0].rows.size).isEqualTo(1)
+        assertEquals(expectedCards[0].rows, dynamicCards[0].rows)
+        assertThat(dynamicCards[0].action).isInstanceOf(MySiteCardAndItem.Card.Dynamic.ActionSource.Button::class.java)
+        val expected = expectedCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Button
+        val actual = dynamicCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Button
+        assertEquals(expected?.title, actual?.title)
+        assertEquals(expected?.url, actual?.url)
+    }
+
+    @Test
+    fun testBuildWithInvalidActionTitle() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertThat(dynamicCards[0].action).isInstanceOf(MySiteCardAndItem.Card.Dynamic.ActionSource.Card::class.java)
+        val actual = dynamicCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Card
+        assertEquals(DYNAMIC_CARD_URL, actual?.url)
+    }
+
+    @Test
+    fun testBuildWithInvalidActionTitleAndUrl() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertThat(dynamicCards[0].action).isNull()
+    }
+
+    @Test
+    fun testBuildWithInvalidUrl() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_URL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertThat(dynamicCards[0].action).isNull()
+    }
+
+    @Test
+    fun testBuildWithEmptyPosition() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.BOTTOM)
+        assertThat(dynamicCards).isNull()
+    }
+
+    @Test
+    fun testBuildWithInvalidParams() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = null,
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(dynamicCards).isNull()
+    }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSliceTest.kt
new file mode 100644
index 000000000000..8ea8158bc555
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSliceTest.kt
@@ -0,0 +1,59 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardRowModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel
+
+/* DYNAMIC CARDS */
+private const val DYNAMIC_CARD_ID = "year_in_review_2023"
+private const val DYNAMIC_CARD_TITLE = "News"
+private const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
+private const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
+private const val DYNAMIC_CARD_URL = "https://wordpress.com"
+private const val DYNAMIC_CARD_ACTION = "Call to action"
+private const val DYNAMIC_CARD_ORDER = "top"
+private const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
+private const val DYNAMIC_CARD_ROW_TITLE = "Row title"
+private const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
+
+private val DYNAMIC_CARD_ROW_MODEL = DynamicCardRowModel(
+    icon = DYNAMIC_CARD_ROW_ICON,
+    title = DYNAMIC_CARD_ROW_TITLE,
+    description = DYNAMIC_CARD_ROW_DESCRIPTION
+)
+
+private val DYNAMIC_CARD_MODEL = DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARDS_MODEL = CardModel.DynamicCardsModel(
+    pages = listOf(DYNAMIC_CARD_MODEL)
+)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DynamicCardsViewModelSliceTest : BaseUnitTest() {
+    lateinit var dynamicCardsViewModelSlice: DynamicCardsViewModelSlice
+
+    @Before
+    fun setUp() {
+        dynamicCardsViewModelSlice = DynamicCardsViewModelSlice()
+    }
+
+    @Test
+    fun testGetBuilderParams() {
+        val builderParams = dynamicCardsViewModelSlice.getBuilderParams(DYNAMIC_CARDS_MODEL)
+        assertThat(builderParams.dynamicCards).isEqualTo(DYNAMIC_CARDS_MODEL)
+    }
+}