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

Issue:
[Dynamic Cards] Update Endpoint Response
### Endpoint
`sites/{SITE_ID}/dashboard/cards-data`

### The Full Response: 
```
{
  "todays_stats": {...},
  "posts": {...},
  "pages": [...],
  "activity": {...},
  "dynamic": [Card, Card, ...]
}
```

### The Card Object:
```
{
  "id": "year_in_review_2023",
  "title": "News",
  "remote_feature_flag": "dynamic_dashboard_cards",
  "featured_image": "https://path/to/image",
  "url": "https://wordpress.com",
  "action": "Call to action",
  "order": "top",
  "rows": [
    {
      "icon": "https://path/to/image",
      "title": "xyz",
      "description": "xyz"
    },
    {
      "icon": "https://path/to/image",
      "title": "xyz",
      "description": "xyz"
    }
  ]
}
```
order will have 2 possible values: “top” and “bottom”.


Discussion hints:
nan

Pull request: [Dynamic Cards] Update Endpoint Response
Fixes #19734

-----

## Description
This PR adds Dynamic Cards support in the dashboard/cards-data Endpoint Response.

Note:
* Adding the `Not Ready for Merge` status till we test with the backend, merge the accompanying FluxC PR and update the reference to point to `trunk` 

## To Test:
* Enable https://github.com/wordpress-mobile/WordPress-Android/pull/19765
* Verify that the existing dashboard cards appear as expected
* TODO: Test with a sandbox

-----

## Regression Notes

1. Potential unintended areas of impact

Jetpack app 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 new test case

-----

## 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.dashboard.CardsSourceTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
index 182673578d15..9339b1d9abe8 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
@@ -17,6 +17,7 @@ import org.wordpress.android.ui.mysite.MySiteUiState.PartialState.CardsUpdate
 import org.wordpress.android.ui.mysite.SelectedSiteRepository
 import org.wordpress.android.ui.mysite.cards.dashboard.activity.DashboardActivityLogCardFeatureUtils
 import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.config.DynamicDashboardCardsFeatureConfig
 import javax.inject.Inject
 import javax.inject.Named
 
@@ -27,7 +28,8 @@ class CardsSource @Inject constructor(
     private val cardsStore: CardsStore,
     private val dashboardActivityLogCardFeatureUtils: DashboardActivityLogCardFeatureUtils,
     @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
-    private val appPrefsWrapper: AppPrefsWrapper
+    private val appPrefsWrapper: AppPrefsWrapper,
+    private val dynamicDashboardCardsFeatureConfig: DynamicDashboardCardsFeatureConfig,
 ) : MySiteRefreshSource<CardsUpdate> {
     override val refresh = MutableLiveData(false)
 
@@ -103,6 +105,7 @@ class CardsSource @Inject constructor(
         if (shouldRequestPagesCard(selectedSite)) add(Type.PAGES)
         if (dashboardActivityLogCardFeatureUtils.shouldRequestActivityCard(selectedSite)) add(Type.ACTIVITY)
         add(Type.POSTS)
+        if (shouldRequestDynamicCards()) add(Type.DYNAMIC)
     }.toList()
 
     private fun shouldRequestPagesCard(selectedSite: SiteModel): Boolean {
@@ -114,6 +117,10 @@ class CardsSource @Inject constructor(
         return !appPrefsWrapper.getShouldHideTodaysStatsDashboardCard(selectedSite.siteId)
     }
 
+    private fun shouldRequestDynamicCards(): Boolean {
+        return dynamicDashboardCardsFeatureConfig.isEnabled() // TODO We should also add check for hidden cards here
+    }
+
     private fun MediatorLiveData<CardsUpdate>.postErrorState() {
         val lastStateCards = this.value?.cards
         val showErrorCard = lastStateCards.isNullOrEmpty()
diff --git a/build.gradle b/build.gradle
index 9c8e92369cfa..574f4058ec29 100644
--- a/build.gradle
+++ b/build.gradle
@@ -24,7 +24,7 @@ ext {
     automatticTracksVersion = '3.3.0'
     gutenbergMobileVersion = 'v1.110.0-alpha1'
     wordPressAztecVersion = 'v1.9.0'
-    wordPressFluxCVersion = '2.59.0'
+    wordPressFluxCVersion = 'trunk-01d386ea4c0a9dbda7c06432e555300a97a231f5'
     wordPressLoginVersion = '1.10.0'
     wordPressPersistentEditTextVersion = '1.0.2'
     wordPressUtilsVersion = '3.10.0'


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt
index 01014309a99e..08c87200a9f7 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt
@@ -19,6 +19,10 @@ import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel.Post
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel.PageCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardRowModel
 import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsUtils
 import org.wordpress.android.fluxc.store.dashboard.CardsStore
 import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsError
@@ -29,6 +33,7 @@ import org.wordpress.android.ui.mysite.MySiteUiState.PartialState.CardsUpdate
 import org.wordpress.android.ui.mysite.SelectedSiteRepository
 import org.wordpress.android.ui.mysite.cards.dashboard.activity.DashboardActivityLogCardFeatureUtils
 import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.config.DynamicDashboardCardsFeatureConfig
 
 /* SITE */
 
@@ -57,6 +62,18 @@ const val PAGE_MODIFIED_ON = "2023-03-02 10:26:53"
 const val PAGE_STATUS = "publish"
 const val PAGE_DATE = "2023-03-02 10:30:53"
 
+/* DYNAMIC CARDS */
+const val DYNAMIC_CARD_ID = "year_in_review_2023"
+const val DYNAMIC_CARD_TITLE = "News"
+const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
+const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
+const val DYNAMIC_CARD_URL = "https://wordpress.com"
+const val DYNAMIC_CARD_ACTION = "Call to action"
+const val DYNAMIC_CARD_ORDER = "top"
+const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
+const val DYNAMIC_CARD_ROW_TITLE = "Row title"
+const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
+
 /* ACTIVITY */
 const val ACTIVITY_ID = "activity123"
 const val ACTIVITY_SUMMARY = "activity"
@@ -110,6 +127,27 @@ private val PAGES_MODEL = PagesCardModel(
     pages = listOf(PAGE_MODEL)
 )
 
+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 = CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARDS_MODEL = DynamicCardsModel(
+    pages = listOf(DYNAMIC_CARD_MODEL)
+)
+
 private val ACTIVITY_LOG_MODEL = ActivityLogModel(
     summary = ACTIVITY_SUMMARY,
     content = FormattableContent(text = ACTIVITY_CONTENT),
@@ -138,7 +176,8 @@ private val CARDS_MODEL: List<CardModel> = listOf(
     TODAYS_STATS_CARDS_MODEL,
     POSTS_MODEL,
     PAGES_MODEL,
-    ACTIVITY_CARD_MODEL
+    ACTIVITY_CARD_MODEL,
+    DYNAMIC_CARDS_MODEL,
 )
 
 private val DEFAULT_CARD_TYPE = listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.POSTS)
@@ -146,6 +185,8 @@ private val PAGES_FEATURED_ENABLED_CARD_TYPE =
     listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.PAGES, CardModel.Type.POSTS)
 private val ACTIVITY_FEATURED_ENABLED_CARD_TYPE =
     listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.ACTIVITY, CardModel.Type.POSTS)
+private val DYNAMIC_CARDS_ENABLED_CARD_TYPE =
+    listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.POSTS, CardModel.Type.DYNAMIC)
 
 @ExperimentalCoroutinesApi
 class CardsSourceTest : BaseUnitTest() {
@@ -164,6 +205,9 @@ class CardsSourceTest : BaseUnitTest() {
     @Mock
     private lateinit var appPrefsWrapper: AppPrefsWrapper
 
+    @Mock
+    private lateinit var dynamicDashboardCardsFeatureConfig: DynamicDashboardCardsFeatureConfig
+
     private lateinit var cardSource: CardsSource
 
     private val data = CardsResult(
@@ -181,7 +225,8 @@ class CardsSourceTest : BaseUnitTest() {
             cardsStore,
             dashboardActivityLogCardFeatureUtils,
             testDispatcher(),
-            appPrefsWrapper
+            appPrefsWrapper,
+            dynamicDashboardCardsFeatureConfig
         )
     }
 
@@ -189,7 +234,8 @@ class CardsSourceTest : BaseUnitTest() {
         isDashboardCardActivityLogEnabled: Boolean = false,
         isRequestPages: Boolean = false,
         isPagesCardHidden: Boolean = false,
-        isTodaysStatsCardHidden: Boolean = false
+        isTodaysStatsCardHidden: Boolean = false,
+        isDynamicCardsEnabled: Boolean = false,
     ) {
         whenever(siteModel.id).thenReturn(SITE_LOCAL_ID)
         whenever(selectedSiteRepository.getSelectedSite()).thenReturn(siteModel)
@@ -200,6 +246,8 @@ class CardsSourceTest : BaseUnitTest() {
             .thenReturn(isPagesCardHidden)
         whenever(appPrefsWrapper.getShouldHideTodaysStatsDashboardCard(any()))
             .thenReturn(isTodaysStatsCardHidden)
+        whenever(dynamicDashboardCardsFeatureConfig.isEnabled())
+            .thenReturn(isDynamicCardsEnabled)
     }
 
     /* GET DATA */
@@ -232,7 +280,8 @@ class CardsSourceTest : BaseUnitTest() {
     fun `given build is invoked, when cards are collected, then data is loaded (database)`() = test {
         setUpMocks(
             isDashboardCardActivityLogEnabled = true,
-            isRequestPages = true
+            isRequestPages = true,
+            isDynamicCardsEnabled = true
         )
         val result = mutableListOf<CardsUpdate>()
         whenever(cardsStore.getCards(siteModel)).thenReturn(flowOf(data))
@@ -264,7 +313,8 @@ class CardsSourceTest : BaseUnitTest() {
     fun `given no error, when build is invoked, then data is only loaded from get cards (database)`() = test {
         setUpMocks(
             isDashboardCardActivityLogEnabled = true,
-            isRequestPages = true
+            isRequestPages = true,
+            isDynamicCardsEnabled = true
         )
         val result = mutableListOf<CardsUpdate>()
         whenever(cardsStore.getCards(siteModel)).thenReturn(flowOf(data))
@@ -531,6 +581,23 @@ class CardsSourceTest : BaseUnitTest() {
             verify(cardsStore).fetchCards(siteModel, ACTIVITY_FEATURED_ENABLED_CARD_TYPE)
         }
 
+    @Test
+    fun `given dynamic cards are enabled, when refresh is invoked, then dynamic cards are requested from network`() =
+        test {
+            setUpMocks(isDynamicCardsEnabled = true)
+            val result = mutableListOf<CardsUpdate>()
+            whenever(cardsStore.getCards(siteModel)).thenReturn(flowOf(data))
+            whenever(cardsStore.fetchCards(siteModel, DYNAMIC_CARDS_ENABLED_CARD_TYPE)).thenReturn(success)
+            cardSource.refresh.observeForever { }
+
+            cardSource.build(testScope(), SITE_LOCAL_ID).observeForever {
+                it?.let { result.add(it) }
+            }
+            advanceUntilIdle()
+
+            verify(cardsStore).fetchCards(siteModel, DYNAMIC_CARDS_ENABLED_CARD_TYPE)
+        }
+
     @Test
     fun `given activity feature disabled, when refresh is invoked, then activity not requested`() =
         test {