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

Issue:
[Tags IA] Implement "retry" action for error state
Implement the `onRetryClick` action to try re-fetching the posts for the tag.


Discussion hints:
Thanks for reporting! 👍 <!-- generated_by_dangermattic -->


Pull request: [Tags Feed] Implement retry action for error state
Fixes #20823 

Implement the retry action by calling the `fetchTag` method for the appropriate tag.
I also adjusted the height of the `Error` item layout, since it was not matching the height from other items, causing the vertical list content to shift.

-----

## To Test:

Suggestion: use the [branch](https://github.com/wordpress-mobile/WordPress-Android/tree/test/20823-reader-tags-feed-retry) `test/20823-reader-tags-feed-retry` to test this PR, since the code there forces the second tag in the list to alternate between the Error and Loaded states every third time when fetching its posts, which is useful for testing the `Retry` behavior.

Ex: first fetch for the tag causes an Error state, second fetch does the same, third fetch loads the posts correctly, then it cycles.

1. Open Jetpack
2. Make sure the `reader_tags_feed` feature config is on (`Debug Settings` -> `Remote features`)
3. Go to Reader
4. Go to `Your Tags` feed
5. Tap `Retry`  on the error/empty item for a tag that failed loading/has no posts
6. **Verify** it tries to fetch the posts again
7. (if using the suggested branch) **Verify** it loads the posts successfully every third fetch (use pull-to-refresh and retry to fetch multiple times)

-----

## Regression Notes

1. Potential unintended areas of impact

    - N/A

8. What I did to test those areas of impact (or what existing automated tests I relied on)

    - N/A

9. What automated tests I added (or what prevented me from doing so)

    - Fixed existing tests
    - Added unit tests for the retry code

-----

## 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.

-----

## Testing Checklist (strike-out the not-applying and unnecessary ones):

- [ ] WordPress.com sites and self-hosted Jetpack sites.
- [x] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [x] 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.reader.viewmodels.tagsfeed.ReaderTagsFeedUiStateMapperTest" --tests "org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModelTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt
index 283341978ef4..cfac4b72a7b1 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapper.kt
@@ -67,7 +67,7 @@ class ReaderTagsFeedUiStateMapper @Inject constructor(
         errorType: ReaderTagsFeedViewModel.ErrorType,
         onTagChipClick: (ReaderTag) -> Unit,
         onMoreFromTagClick: (ReaderTag) -> Unit,
-        onRetryClick: () -> Unit,
+        onRetryClick: (ReaderTag) -> Unit,
         onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit,
     ): ReaderTagsFeedViewModel.TagFeedItem =
         ReaderTagsFeedViewModel.TagFeedItem(
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt
index 525270715e31..30a21829e1bf 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedViewModel.kt
@@ -202,7 +202,9 @@ class ReaderTagsFeedViewModel @Inject constructor(
                     updatedLoadedData[existingIndex] = updatedItem
                 }
 
-            (uiState as? UiState.Loaded)?.copy(data = updatedLoadedData) ?: UiState.Loaded(updatedLoadedData)
+            (uiState as? UiState.Loaded)?.copy(data = updatedLoadedData) ?: UiState.Loaded(
+                updatedLoadedData
+            )
         }
     }
 
@@ -247,8 +249,11 @@ class ReaderTagsFeedViewModel @Inject constructor(
         _actionEvents.value = ActionEvent.OpenTagPostList(readerTag)
     }
 
-    private fun onRetryClick() {
-        // TODO
+    @VisibleForTesting
+    fun onRetryClick(readerTag: ReaderTag) {
+        launch {
+            fetchTag(readerTag)
+        }
     }
 
     @VisibleForTesting
@@ -256,7 +261,13 @@ class ReaderTagsFeedViewModel @Inject constructor(
         launch {
             findPost(postItem.postId, postItem.blogId)?.let {
                 _navigationEvents.postValue(
-                    Event(ReaderNavigationEvents.ShowBlogPreview(it.blogId, it.feedId, it.isFollowedByCurrentUser))
+                    Event(
+                        ReaderNavigationEvents.ShowBlogPreview(
+                            it.blogId,
+                            it.feedId,
+                            it.isFollowedByCurrentUser
+                        )
+                    )
                 )
             }
         }
@@ -268,7 +279,10 @@ class ReaderTagsFeedViewModel @Inject constructor(
             findPost(postItem.postId, postItem.blogId)?.let {
                 readerTracker.trackBlog(
                     AnalyticsTracker.Stat.READER_POST_CARD_TAPPED,
-                    it.blogId, it.feedId, it.isFollowedByCurrentUser, ReaderTracker.SOURCE_TAGS_FEED,
+                    it.blogId,
+                    it.feedId,
+                    it.isFollowedByCurrentUser,
+                    ReaderTracker.SOURCE_TAGS_FEED,
                 )
                 readerPostCardActionsHandler.handleOnItemClicked(
                     it,
@@ -349,7 +363,10 @@ class ReaderTagsFeedViewModel @Inject constructor(
             }
         }
 
-    private fun findTagFeedItemToUpdate(uiState: UiState.Loaded, postItemToUpdate: TagsFeedPostItem) =
+    private fun findTagFeedItemToUpdate(
+        uiState: UiState.Loaded,
+        postItemToUpdate: TagsFeedPostItem
+    ) =
         uiState.data.firstOrNull { tagFeedItem ->
             tagFeedItem.postList is PostList.Loaded && tagFeedItem.postList.items.firstOrNull {
                 it.postId == postItemToUpdate.postId && it.blogId == postItemToUpdate.blogId
@@ -359,7 +376,11 @@ class ReaderTagsFeedViewModel @Inject constructor(
     private fun likePostRemote(postItem: TagsFeedPostItem, isPostLikedUpdated: Boolean) {
         launch {
             findPost(postItem.postId, postItem.blogId)?.let { post ->
-                postLikeUseCase.perform(post, !post.isLikedByCurrentUser, ReaderTracker.SOURCE_TAGS_FEED).collect {
+                postLikeUseCase.perform(
+                    post,
+                    !post.isLikedByCurrentUser,
+                    ReaderTracker.SOURCE_TAGS_FEED
+                ).collect {
                     when (it) {
                         is PostLikeUseCase.PostLikeState.Success -> {
                             // Re-enable like button without changing the current post item UI.
@@ -407,7 +428,8 @@ class ReaderTagsFeedViewModel @Inject constructor(
                     includeBookmark = true,
                     onButtonClicked = ::onMoreMenuButtonClicked,
                 )
-                val photonWidth = (displayUtilsWrapper.getDisplayPixelWidth() * PHOTON_WIDTH_QUALITY_RATION).toInt()
+                val photonWidth =
+                    (displayUtilsWrapper.getDisplayPixelWidth() * PHOTON_WIDTH_QUALITY_RATION).toInt()
                 val photonHeight = (photonWidth * FEATURED_IMAGE_HEIGHT_WIDTH_RATION).toInt()
                 _openMoreMenuEvents.postValue(
                     MoreMenuUiState(
@@ -432,7 +454,11 @@ class ReaderTagsFeedViewModel @Inject constructor(
         }
     }
 
-    private fun onMoreMenuButtonClicked(postId: Long, blogId: Long, type: ReaderPostCardActionType) {
+    private fun onMoreMenuButtonClicked(
+        postId: Long,
+        blogId: Long,
+        type: ReaderPostCardActionType
+    ) {
         launch {
             findPost(postId, blogId)?.let {
                 readerPostCardActionsHandler.onAction(
@@ -502,7 +528,7 @@ class ReaderTagsFeedViewModel @Inject constructor(
 
         data class Error(
             val type: ErrorType,
-            val onRetryClick: () -> Unit
+            val onRetryClick: (ReaderTag) -> Unit
         ) : PostList()
     }
 
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt
index 45996523486b..ef85b60a357c 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/tagsfeed/ReaderTagsFeed.kt
@@ -14,8 +14,10 @@ import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.items
@@ -373,13 +375,14 @@ private fun PostListError(
 ) {
     Column(
         modifier = Modifier
-            .height(250.dp)
+            .heightIn(min = ReaderTagsFeedComposeUtils.PostItemHeight)
             .fillMaxWidth()
             .semantics(mergeDescendants = true) {}
             .padding(start = 60.dp, end = 60.dp),
         horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
     ) {
-        Spacer(modifier = Modifier.height(Margin.ExtraLarge.value))
+        Spacer(modifier = Modifier.height(Margin.Medium.value))
         Icon(
             modifier = Modifier
                 .drawBehind {
@@ -392,7 +395,7 @@ private fun PostListError(
             tint = MaterialTheme.colors.onSurface,
             contentDescription = null
         )
-        Spacer(modifier = Modifier.height(Margin.ExtraExtraMediumLarge.value))
+        Spacer(modifier = Modifier.height(Margin.ExtraMediumLarge.value))
         val tagName = tagChip.tag.tagDisplayName
         Text(
             text = stringResource(id = R.string.reader_tags_feed_error_title, tagName),
@@ -400,7 +403,7 @@ private fun PostListError(
             color = MaterialTheme.colors.onSurface,
             textAlign = TextAlign.Center,
         )
-        Spacer(modifier = Modifier.height(Margin.Medium.value))
+        Spacer(modifier = Modifier.height(Margin.Small.value))
         val errorMessage = when (postList.type) {
             is ErrorType.Default -> stringResource(R.string.reader_tags_feed_loading_error_description)
             is ErrorType.NoContent -> stringResource(R.string.reader_tags_feed_no_content_error_description, tagName)
@@ -415,12 +418,12 @@ private fun PostListError(
             },
             textAlign = TextAlign.Center,
         )
-        Spacer(modifier = Modifier.height(Margin.ExtraLarge.value))
+        Spacer(modifier = Modifier.height(Margin.Large.value))
         Button(
-            onClick = { postList.onRetryClick() },
+            onClick = { postList.onRetryClick(tagChip.tag) },
             modifier = Modifier
                 .height(36.dp)
-                .width(114.dp),
+                .widthIn(min = 114.dp),
             elevation = ButtonDefaults.elevation(
                 defaultElevation = 0.dp,
                 pressedElevation = 0.dp,


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
index d939380018e5..06368089184f 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
@@ -653,6 +653,52 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
         )
     }
 
+    @Test
+    fun `should fetch again when onRetryClick is called`() = testCollectingUiStates {
+        // Given
+        val tag = ReaderTestUtils.createTag("tag")
+        val posts = ReaderPostList().apply {
+            add(ReaderPost())
+        }
+        val error = ReaderPostFetchException("error")
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer {
+            delay(100)
+            throw error
+        }.doSuspendableAnswer {
+            delay(100)
+            posts
+        }
+
+        mockMapInitialTagFeedItems()
+        mockMapLoadingTagFeedItems()
+        mockMapLoadedTagFeedItems()
+        mockMapErrorTagFeedItems()
+
+        viewModel.onTagsChanged(listOf(tag))
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag))
+        advanceUntilIdle()
+
+        assertThat(collectedUiStates.last()).isEqualTo(
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(getErrorTagFeedItem(tag))
+            )
+        )
+
+        // When
+        viewModel.onRetryClick(tag)
+        advanceUntilIdle()
+        viewModel.onItemEnteredView(getInitialTagFeedItem(tag))
+        advanceUntilIdle()
+
+        // Then
+        assertThat(collectedUiStates).contains(
+            ReaderTagsFeedViewModel.UiState.Loaded(
+                data = listOf(getLoadedTagFeedItem(tag))
+            )
+        )
+    }
+
     private fun mockMapInitialTagFeedItems() {
         whenever(readerTagsFeedUiStateMapper.mapInitialPostsUiState(any(), any(), any(), any(), any(), any()))
             .thenAnswer {
@@ -704,9 +750,7 @@ class ReaderTagsFeedViewModelTest : BaseUnitTest() {
 
     private fun getErrorTagFeedItem(tag: ReaderTag) = ReaderTagsFeedViewModel.TagFeedItem(
         ReaderTagsFeedViewModel.TagChip(tag, {}, {}),
-        ReaderTagsFeedViewModel.PostList.Error(
-            ReaderTagsFeedViewModel.ErrorType.Default, {}
-        ),
+        ReaderTagsFeedViewModel.PostList.Error(ReaderTagsFeedViewModel.ErrorType.Default, {}),
     )
 
     private fun testCollectingUiStates(block: suspend TestScope.() -> Unit) = test {
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt
index c82f8e9ff3af..e0bfbde05d8c 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/tagsfeed/ReaderTagsFeedUiStateMapperTest.kt
@@ -227,7 +227,7 @@ class ReaderTagsFeedUiStateMapperTest : BaseUnitTest() {
         val errorType = ReaderTagsFeedViewModel.ErrorType.Default
         val onTagChipClick: (ReaderTag) -> Unit = {}
         val onMoreFromTagClick: (ReaderTag) -> Unit = {}
-        val onRetryClick = {}
+        val onRetryClick: (ReaderTag) -> Unit = {}
         val onItemEnteredView: (ReaderTagsFeedViewModel.TagFeedItem) -> Unit = {}
         // When
         val actual = classToTest.mapErrorTagFeedItem(