diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt index 79b94051c384..94e9d633c2bd 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/NewStatsActivity.kt @@ -186,7 +186,7 @@ class NewStatsActivity : BaseAppCompatActivity() { appPrefsWrapper .setNewStatsIntroShown(true) }, - onReferrerChildClick = { url -> + onStatsUrlClick = { url -> activityNavigator.openInCustomTab(this, url) }, onPostItemClick = ::openPostDetailStats @@ -292,7 +292,7 @@ private fun NewStatsScreen( onSwitchToOldStats: () -> Unit = {}, showIntroBottomSheet: Boolean = false, onIntroDismissed: () -> Unit = {}, - onReferrerChildClick: (String) -> Unit = {}, + onStatsUrlClick: (String) -> Unit = {}, onPostItemClick: (MostViewedItem) -> Unit = {} ) { val viewsStatsViewModel: ViewsStatsViewModel = viewModel() @@ -434,7 +434,7 @@ private fun NewStatsScreen( StatsTabContent( tab = tabs[page], viewsStatsViewModel = viewsStatsViewModel, - onReferrerChildClick = onReferrerChildClick, + onStatsUrlClick = onStatsUrlClick, onPostItemClick = onPostItemClick ) } @@ -446,16 +446,18 @@ private fun NewStatsScreen( private fun StatsTabContent( tab: StatsTab, viewsStatsViewModel: ViewsStatsViewModel, - onReferrerChildClick: (String) -> Unit = {}, + onStatsUrlClick: (String) -> Unit = {}, onPostItemClick: (MostViewedItem) -> Unit = {} ) { when (tab) { StatsTab.TRAFFIC -> TrafficTabContent( viewsStatsViewModel = viewsStatsViewModel, - onReferrerChildClick = onReferrerChildClick, + onStatsUrlClick = onStatsUrlClick, onPostItemClick = onPostItemClick ) - StatsTab.INSIGHTS -> InsightsTabContent() + StatsTab.INSIGHTS -> InsightsTabContent( + onStatsUrlClick = onStatsUrlClick + ) StatsTab.SUBSCRIBERS -> SubscribersTabContent() } } @@ -476,7 +478,7 @@ private fun TrafficTabContent( devicesViewModel: DevicesViewModel = viewModel(), utmViewModel: UtmViewModel = viewModel(), newStatsViewModel: NewStatsViewModel = viewModel(), - onReferrerChildClick: (String) -> Unit = {}, + onStatsUrlClick: (String) -> Unit = {}, onPostItemClick: (MostViewedItem) -> Unit = {} ) { val context = LocalContext.current @@ -767,7 +769,7 @@ private fun TrafficTabContent( onMoveToTop = { newStatsViewModel.moveCardToTop(cardType) }, onMoveDown = { newStatsViewModel.moveCardDown(cardType) }, onMoveToBottom = { newStatsViewModel.moveCardToBottom(cardType) }, - onChildClick = onReferrerChildClick + onUrlClick = onStatsUrlClick ) StatsCardType.LOCATIONS -> LocationsCard( uiState = locationsUiState, @@ -923,7 +925,8 @@ private fun TrafficTabContent( ?.isAuthError == true, getAdminUrl = clicksViewModel::getAdminUrl, context = context - ) + ), + onUrlClick = onStatsUrlClick ) StatsCardType.SEARCH_TERMS -> MostViewedCard( uiState = searchTermsUiState, @@ -1105,7 +1108,8 @@ private fun InsightsTabContent( mostPopularDayViewModel: MostPopularDayViewModel = viewModel(), mostPopularTimeViewModel: MostPopularTimeViewModel = viewModel(), tagsAndCategoriesViewModel: TagsAndCategoriesViewModel = viewModel(), - insightsViewModel: InsightsViewModel = viewModel() + insightsViewModel: InsightsViewModel = viewModel(), + onStatsUrlClick: (String) -> Unit = {} ) { val context = LocalContext.current val yearInReviewUiState by yearInReviewViewModel.uiState.collectAsState() @@ -1292,7 +1296,8 @@ private fun InsightsTabContent( onMoveUp = { insightsViewModel.moveCardUp(cardType) }, onMoveToTop = { insightsViewModel.moveCardToTop(cardType) }, onMoveDown = { insightsViewModel.moveCardDown(cardType) }, - onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) } + onMoveToBottom = { insightsViewModel.moveCardToBottom(cardType) }, + onUrlClick = onStatsUrlClick ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt index 06f00c96bd28..571298613695 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/clicks/ClicksViewModel.kt @@ -5,10 +5,8 @@ import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.mysite.SelectedSiteRepository import org.wordpress.android.ui.newstats.StatsPeriod import org.wordpress.android.ui.newstats.mostviewed.BaseStatsCardViewModel -import org.wordpress.android.ui.newstats.mostviewed.MostViewedChange -import org.wordpress.android.ui.newstats.mostviewed.MostViewedDetailItem import org.wordpress.android.ui.newstats.mostviewed.StatsCardFetchResult -import org.wordpress.android.ui.newstats.repository.ClickItemData +import org.wordpress.android.ui.newstats.mostviewed.toDetailItem import org.wordpress.android.ui.newstats.repository.ClicksResult import org.wordpress.android.ui.newstats.repository.StatsRepository import org.wordpress.android.viewmodel.ResourceProvider @@ -55,15 +53,4 @@ class ClicksViewModel @Inject constructor( } } } - - private fun ClickItemData.toDetailItem( - id: Long - ) = MostViewedDetailItem( - id = id, - title = name, - views = clicks, - change = MostViewedChange.fromChange( - clicksChange, clicksChangePercent - ) - ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt index 61a444403d1a..38051c0312e9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/components/StatsCardCommon.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.Button import androidx.compose.material3.Icon @@ -236,6 +237,19 @@ fun StatsListHeader( } } +/** + * "Opens a link" affordance shown on a stats row that navigates to a URL when tapped. + */ +@Composable +fun StatsOpenLinkIcon() { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = stringResource(R.string.stats_open_link), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) +} + /** * Common row container with background percentage bar. * Used for country rows, author rows, and similar list items. diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt index f2cc8db3ba3e..bcac4ff6fd42 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSource.kt @@ -435,10 +435,12 @@ sealed class ReferrersDataResult { } /** - * A single referrer item from the API. + * A single referrer item from the API. [url] is null for a group with no link of its own + * (e.g. "Search Engines"), whose links live on its [children]. */ data class ReferrerDataItem( val name: String, + val url: String?, val views: Long, val children: List = emptyList() ) @@ -573,10 +575,19 @@ sealed class ClicksDataResult { } /** - * A single click item from the API. + * A single click item from the API. [url] is null for a grouped source with no link of its own, + * whose links live on its [children]. */ data class ClickDataItem( val name: String, + val url: String?, + val clicks: Long, + val children: List = emptyList() +) + +data class ClickChildDataItem( + val name: String, + val url: String?, val clicks: Long ) @@ -744,11 +755,13 @@ data class TagGroupData( ) /** - * A single tag or category item. + * A single tag or category item. [link] is the tag's archive page, opened when the row is tapped, + * and is null when the tag has none. */ data class TagData( val tagType: String, - val name: String + val name: String, + val link: String? = null ) /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt index 01a25e93bc41..4e374950980f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/datasource/StatsDataSourceImpl.kt @@ -6,6 +6,7 @@ import rs.wordpress.api.kotlin.WpComApiClient import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.StatsCityViewsParams import uniffi.wp_api.StatsCityViewsPeriod +import uniffi.wp_api.StatsClicksChild import uniffi.wp_api.StatsClicksParams import uniffi.wp_api.StatsClicksPeriod import uniffi.wp_api.StatsCountryViewsParams @@ -259,6 +260,7 @@ class StatsDataSourceImpl @Inject constructor( groups.map { group -> ReferrerDataItem( name = group.name.orEmpty(), + url = group.url, views = group.total?.toLong() ?: 0L, children = group.results.toChildren() ) @@ -632,8 +634,10 @@ class StatsDataSourceImpl @Inject constructor( clicks.map { entry -> ClickDataItem( name = entry.name.orEmpty(), + url = entry.url, clicks = entry.views?.toLong() - ?: 0L + ?: 0L, + children = entry.children.toClickChildren() ) } ) @@ -646,6 +650,15 @@ class StatsDataSourceImpl @Inject constructor( } } + private fun List?.toClickChildren(): List = + orEmpty().map { child -> + ClickChildDataItem( + name = child.name.orEmpty(), + url = child.url, + clicks = child.views?.toLong() ?: 0L + ) + } + override suspend fun fetchDevicesScreensize( siteId: Long, dateRange: StatsDateRange, @@ -1177,7 +1190,8 @@ class StatsDataSourceImpl @Inject constructor( tags = group.tags.map { tag -> TagData( tagType = tag.tagType, - name = tag.name + name = tag.name, + link = tag.link ) }, views = group.views.toLong() diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt index fcdb32281b7d..50682933f7e7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/BaseStatsCardViewModel.kt @@ -194,7 +194,9 @@ abstract class BaseStatsCardViewModel( id = id, title = title, views = views, - change = change + change = change, + children = children, + url = url ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt index 18f6864f8713..e52e767cb430 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCard.kt @@ -56,7 +56,7 @@ fun MostViewedCard( onMoveDown: (() -> Unit)? = null, onMoveToBottom: (() -> Unit)? = null, onOpenWpAdmin: (() -> Unit)? = null, - onChildClick: (String) -> Unit = {}, + onUrlClick: (String) -> Unit = {}, onItemClick: ((MostViewedItem) -> Unit)? = null ) { val borderColor = MaterialTheme.colorScheme.outlineVariant @@ -78,7 +78,7 @@ fun MostViewedCard( is MostViewedCardUiState.Loaded -> LoadedContent( uiState, cardType, onShowAllClick, onRemoveCard, cardPosition, onMoveUp, onMoveToTop, onMoveDown, onMoveToBottom, - onChildClick, onItemClick + onUrlClick, onItemClick ) is MostViewedCardUiState.Error -> ErrorContent( uiState, cardType, onRetry, onRemoveCard, @@ -172,7 +172,7 @@ private fun LoadedContent( onMoveToTop: (() -> Unit)?, onMoveDown: (() -> Unit)?, onMoveToBottom: (() -> Unit)?, - onChildClick: (String) -> Unit, + onUrlClick: (String) -> Unit, onItemClick: ((MostViewedItem) -> Unit)? ) { Column( @@ -199,18 +199,24 @@ private fun LoadedContent( val percentage = if (state.maxViewsForBar > 0) { item.views.toFloat() / state.maxViewsForBar.toFloat() } else 0f - // Key on the index plus the item id: the index guarantees uniqueness (referrer ids - // are derived from name.hashCode() and can collide on empty/duplicate names), while - // the id resets the row's saved expanded state when a different referrer lands at - // this position after a period reload. rememberSaveable keeps it across rotation. - key(index, item.id) { + // Key on the index plus the title: the index guarantees uniqueness (titles can + // repeat), while the title resets the row's saved expanded state when a different + // entry lands at this position after a reload. rememberSaveable keeps it across + // rotation. + // + // Deliberately not item.id — that only tracks content for some card types. Posts + // use the backend id and referrers a name hash, but clicks, search terms, video + // plays and file downloads number their items by position, so the id would never + // change and a refresh could leave a row expanded onto whatever replaced it. + key(index, item.title) { MostViewedExpandableRow( title = item.title, views = item.views, change = item.change, children = item.children, percentage = percentage, - onChildClick = onChildClick, + onUrlClick = onUrlClick, + url = item.url, onItemClick = onItemClick?.let { { it(item) } } ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt index 540bce221f01..c5005be305b7 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCardUiState.kt @@ -38,7 +38,8 @@ sealed class MostViewedCardUiState { * @param title The title/name of the item (post title or referrer name) * @param views The number of views * @param change The percentage change compared to previous period - * @param url The item's URL (posts only, null for referrers) + * @param url The item's URL. Posts open it as detail stats; referrers and clicks open it in a + * Custom Tab. Null when the item has no link of its own. * @param postType The API post type, e.g. "post"/"page" (posts only) */ data class MostViewedItem( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt index 1996c026b10b..2493fd287916 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedCommonComposables.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Icon @@ -31,6 +30,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.wordpress.android.R import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.components.StatsOpenLinkIcon import org.wordpress.android.ui.newstats.util.formatStatValue /** @@ -39,6 +39,9 @@ import org.wordpress.android.ui.newstats.util.formatStatValue * screen ([MostViewedDetailActivity]); they differ only by the leading [position] number (detail * only) and the child [childStartPadding] indent. * + * What a tap does — expand, open a link, or hand off to the caller — is decided by + * [statsRowAction]; see there for the precedence and why it matters. + * * The caller is expected to wrap this row in a `key(...)` so the [rememberSaveable] expanded state * is scoped to the item and survives configuration changes (e.g. rotation). * @@ -47,9 +50,9 @@ import org.wordpress.android.ui.newstats.util.formatStatValue * @param change The change badge shown under the views * @param children The child rows revealed when expanded (empty = no chevron, not expandable) * @param percentage Bar fill percentage (0f..1f) - * @param onChildClick Invoked with a child's URL when a linkable child row is tapped - * @param onItemClick Invoked when a childless row is tapped (e.g. opens a post's detail stats); - * rows with children keep their expand/collapse tap behavior + * @param onUrlClick Invoked with the URL when a linkable row (this row or a child) is tapped + * @param url The URL passed to [onUrlClick] when this row is tapped + * @param onItemClick Invoked when a childless row is tapped, e.g. opens a post's detail stats * @param position Optional 1-based position number shown on the detail screen * @param childStartPadding Indent applied to the expanded child rows */ @@ -60,17 +63,21 @@ internal fun MostViewedExpandableRow( change: MostViewedChange, children: List, percentage: Float, - onChildClick: (String) -> Unit, + onUrlClick: (String) -> Unit, + url: String? = null, onItemClick: (() -> Unit)? = null, position: Int? = null, childStartPadding: Dp = 24.dp ) { val hasChildren = children.isNotEmpty() + val action = statsRowAction(hasChildren, url, onItemClick) + val linkUrl = (action as? StatsRowAction.OpenUrl)?.url var expanded by rememberSaveable { mutableStateOf(false) } - val clickModifier = when { - hasChildren -> Modifier.clickable { expanded = !expanded } - onItemClick != null -> Modifier.clickable { onItemClick() } - else -> Modifier + val clickModifier = when (action) { + StatsRowAction.Expand -> Modifier.clickable { expanded = !expanded } + is StatsRowAction.Item -> Modifier.clickable { action.onClick() } + is StatsRowAction.OpenUrl -> Modifier.clickable { onUrlClick(action.url) } + StatsRowAction.None -> Modifier } Column { @@ -104,6 +111,9 @@ internal fun MostViewedExpandableRow( if (hasChildren) { MostViewedExpandChevron(expanded = expanded) Spacer(modifier = Modifier.width(4.dp)) + } else if (linkUrl != null) { + StatsOpenLinkIcon() + Spacer(modifier = Modifier.width(4.dp)) } Text( text = formatStatValue(views), @@ -121,7 +131,7 @@ internal fun MostViewedExpandableRow( children = children, expanded = expanded, startPadding = childStartPadding, - onChildClick = onChildClick + onUrlClick = onUrlClick ) } } @@ -151,7 +161,7 @@ internal fun MostViewedExpandableChildren( children: List, expanded: Boolean, startPadding: Dp, - onChildClick: (String) -> Unit + onUrlClick: (String) -> Unit ) { AnimatedVisibility(visible = expanded) { val maxChildViews = children.maxOfOrNull { it.views } ?: 0L @@ -163,7 +173,7 @@ internal fun MostViewedExpandableChildren( val childPercentage = if (maxChildViews > 0) { child.views.toFloat() / maxChildViews.toFloat() } else 0f - MostViewedChildRow(child = child, percentage = childPercentage, onChildClick = onChildClick) + MostViewedChildRow(child = child, percentage = childPercentage, onUrlClick = onUrlClick) } } } @@ -173,11 +183,11 @@ internal fun MostViewedExpandableChildren( private fun MostViewedChildRow( child: MostViewedChildItem, percentage: Float, - onChildClick: (String) -> Unit + onUrlClick: (String) -> Unit ) { val url = child.url val isClickable = !url.isNullOrBlank() - val clickModifier = if (isClickable) Modifier.clickable { onChildClick(url) } else Modifier + val clickModifier = if (isClickable) Modifier.clickable { onUrlClick(url) } else Modifier StatsListRowContainer(percentage = percentage, modifier = clickModifier) { Row( @@ -202,12 +212,7 @@ private fun MostViewedChildRow( ) if (isClickable) { Spacer(modifier = Modifier.width(4.dp)) - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = stringResource(R.string.stats_open_referrer_link), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + StatsOpenLinkIcon() } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt index f277124850b6..83c02b14e5d1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailActivity.kt @@ -102,7 +102,7 @@ class MostViewedDetailActivity : BaseAppCompatActivity() { onOpenWpAdmin = { viewModel.getAdminUrl()?.let { ActivityLauncher.openUrlExternal(this, it) } }, - onChildClick = { url -> activityNavigator.openInCustomTab(this, url) }, + onUrlClick = { url -> activityNavigator.openInCustomTab(this, url) }, onItemClick = if (cardType == StatsCardType.MOST_VIEWED_POSTS_AND_PAGES) { ::openPostDetailStats } else { @@ -216,7 +216,7 @@ private fun MostViewedDetailScreen( onBackPressed: () -> Unit, onRetry: () -> Unit = {}, onOpenWpAdmin: () -> Unit = {}, - onChildClick: (String) -> Unit = {}, + onUrlClick: (String) -> Unit = {}, onItemClick: ((MostViewedDetailItem) -> Unit)? = null ) { val title = stringResource(cardType.displayNameResId) @@ -250,7 +250,7 @@ private fun MostViewedDetailScreen( is MostViewedDetailUiState.Loaded -> DetailLoadedContent( state = uiState, valueHeaderResId = valueHeaderResId, - onChildClick = onChildClick, + onUrlClick = onUrlClick, onItemClick = onItemClick, modifier = contentModifier ) @@ -262,7 +262,7 @@ private fun MostViewedDetailScreen( private fun DetailLoadedContent( state: MostViewedDetailUiState.Loaded, valueHeaderResId: Int, - onChildClick: (String) -> Unit, + onUrlClick: (String) -> Unit, onItemClick: ((MostViewedDetailItem) -> Unit)?, modifier: Modifier = Modifier ) { @@ -301,7 +301,8 @@ private fun DetailLoadedContent( change = item.change, children = item.children, percentage = percentage, - onChildClick = onChildClick, + onUrlClick = onUrlClick, + url = item.url, onItemClick = onItemClick?.let { { it(item) } }, position = index + 1, childStartPadding = 32.dp diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt index 2cd1d1e4c84c..196518a6b65f 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcher.kt @@ -103,13 +103,6 @@ class MostViewedDetailFetcher @Inject constructor( is FileDownloadsResult.Error -> StatsCardFetchResult.Error(messageResId, isAuthError) } - private fun ClickItemData.toDetailItem(id: Long) = MostViewedDetailItem( - id = id, - title = name, - views = clicks, - change = MostViewedChange.fromChange(clicksChange, clicksChangePercent) - ) - private fun SearchTermItemData.toDetailItem(id: Long) = MostViewedDetailItem( id = id, title = name, @@ -131,3 +124,15 @@ class MostViewedDetailFetcher @Inject constructor( change = MostViewedChange.fromChange(downloadsChange, downloadsChangePercent) ) } + +/** Maps a clicks item onto the shared detail shape. Shared by the clicks card and this fetcher. */ +internal fun ClickItemData.toDetailItem(id: Long) = MostViewedDetailItem( + id = id, + title = name, + views = clicks, + change = MostViewedChange.fromChange(clicksChange, clicksChangePercent), + children = children.map { child -> + MostViewedChildItem(name = child.name, url = child.url, views = child.views) + }, + url = url +) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/StatsRowAction.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/StatsRowAction.kt new file mode 100644 index 000000000000..46e5075540a3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/mostviewed/StatsRowAction.kt @@ -0,0 +1,42 @@ +package org.wordpress.android.ui.newstats.mostviewed + +/** What tapping a stats list row should do. Exactly one of these — see [statsRowAction]. */ +internal sealed interface StatsRowAction { + /** Reveal or hide the row's children. */ + data object Expand : StatsRowAction + + /** Invoke [onClick], e.g. a post row opening its detail stats. */ + data class Item(val onClick: () -> Unit) : StatsRowAction + + /** Open [url], e.g. a referrer, click or tag opening its page in a Custom Tab. */ + data class OpenUrl(val url: String) : StatsRowAction + + /** The row is inert. */ + data object None : StatsRowAction +} + +/** + * Decides what a stats row does when tapped. A row never both expands and navigates, matching the + * old stats screen. + * + * The precedence is why this is a testable function rather than inline in a composable: + * + * 1. A row with children **expands** — a group header never navigates. + * 2. [onItemClick] wins over [url], so a post row opens its detail stats rather than opening the + * post in a browser. Posts and referrers both carry a `url`, so without this a post row would + * navigate to the wrong place and show a misleading "open link" affordance. + * 3. Otherwise a non-blank [url] opens. + * + * Each result carries what it needs to act, so callers can't reach a state where the row is + * clickable but has nothing to invoke. + */ +internal fun statsRowAction( + hasChildren: Boolean, + url: String?, + onItemClick: (() -> Unit)? +): StatsRowAction = when { + hasChildren -> StatsRowAction.Expand + onItemClick != null -> StatsRowAction.Item(onItemClick) + !url.isNullOrBlank() -> StatsRowAction.OpenUrl(url) + else -> StatsRowAction.None +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt index 607e9c01ef35..58f8144ceaa8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/repository/StatsRepository.kt @@ -1017,7 +1017,8 @@ class StatsRepository @Inject constructor( url = child.url, views = child.views ) - } + }, + url = item.url ) }, totalViews = totalViews, @@ -1428,7 +1429,19 @@ class StatsRepository @Inject constructor( keyOf = { it.name }, metricOf = { it.clicks }, mapItem = { item, prev -> - ClickItemData(item.name, item.clicks, prev) + ClickItemData( + name = item.name, + clicks = item.clicks, + previousClicks = prev, + url = item.url, + children = item.children.map { child -> + MostViewedChildData( + name = child.name, + url = child.url, + views = child.clicks + ) + } + ) }, buildSuccess = { items, total, change, pct -> ClicksResult.Success( @@ -2390,7 +2403,9 @@ sealed class ClicksResult { data class ClickItemData( val name: String, val clicks: Long, - val previousClicks: Long + val previousClicks: Long, + val url: String? = null, + val children: List = emptyList() ) { val clicksChange: Long get() = clicks - previousClicks val clicksChangePercent: Double diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt index c0f403434e0d..adea994f2fa5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCard.kt @@ -47,7 +47,8 @@ fun TagsAndCategoriesCard( onMoveUp: (() -> Unit)? = null, onMoveToTop: (() -> Unit)? = null, onMoveDown: (() -> Unit)? = null, - onMoveToBottom: (() -> Unit)? = null + onMoveToBottom: (() -> Unit)? = null, + onUrlClick: (String) -> Unit = {} ) { StatsCardContainer(modifier = modifier) { when (uiState) { @@ -71,7 +72,8 @@ fun TagsAndCategoriesCard( onMoveUp, onMoveToTop, onMoveDown, - onMoveToBottom + onMoveToBottom, + onUrlClick ) is TagsAndCategoriesCardUiState.Error -> ErrorContent( @@ -187,7 +189,8 @@ private fun LoadedContent( onMoveUp: (() -> Unit)?, onMoveToTop: (() -> Unit)?, onMoveDown: (() -> Unit)?, - onMoveToBottom: (() -> Unit)? + onMoveToBottom: (() -> Unit)?, + onUrlClick: (String) -> Unit ) { val expandedGroups = remember(state.items) { mutableStateMapOf() @@ -233,16 +236,11 @@ private fun LoadedContent( TagGroupRow( item = item, percentage = percentage, - isExpandable = item.isExpandable, isExpanded = isExpanded, - onClick = if (item.isExpandable) { - { - expandedGroups[index] = - !isExpanded - } - } else { - null - } + onExpandToggle = { + expandedGroups[index] = !isExpanded + }, + onUrlClick = onUrlClick ) if (item.isExpandable) { AnimatedVisibility( @@ -251,7 +249,8 @@ private fun LoadedContent( exit = shrinkVertically() ) { ExpandedTagsSection( - tags = item.tags + tags = item.tags, + onUrlClick = onUrlClick ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt index 7e8f1cf2cc4a..25a54252c903 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesCardUiState.kt @@ -28,14 +28,21 @@ data class TagGroupUiItem( val displayType: TagGroupDisplayType ) { val isExpandable: Boolean get() = tags.size > 1 + + /** + * The link opened when this row is tapped. A group of several tags expands instead of + * navigating (matching old stats), so only a lone tag contributes a link. + */ + val link: String? get() = tags.singleOrNull()?.link?.takeIf { it.isNotBlank() } } /** - * A single tag within a tag group. + * A single tag within a tag group. [link] is the tag's archive page, null when it has none. */ data class TagUiItem( val name: String, - val tagType: String + val tagType: String, + val link: String? = null ) /** diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt index 5050e6b86cb8..4dc18f0b2fd2 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesComponents.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.wordpress.android.R import org.wordpress.android.ui.newstats.components.StatsListRowContainer +import org.wordpress.android.ui.newstats.components.StatsOpenLinkIcon import org.wordpress.android.ui.newstats.util.formatStatValue private const val VERTICAL_LINE_ALPHA = 0.3f @@ -63,22 +64,37 @@ fun TagTypeIcon( ) } +/** + * A tag group row, following the same expand-or-navigate rule as the Most Viewed rows (see + * `statsRowAction`): a group of several tags expands, a lone tag opens its archive page, never + * both. + * + * Which one applies is decided here from [item], not by the caller. [onExpandToggle] says *how* + * to toggle, not *whether* to — so the chevron and the tap action are guaranteed to agree. + */ @Composable -@Suppress("LongParameterList") fun TagGroupRow( item: TagGroupUiItem, percentage: Float, - isExpandable: Boolean, isExpanded: Boolean, - onClick: (() -> Unit)?, + onExpandToggle: () -> Unit, + onUrlClick: (String) -> Unit = {}, position: Int? = null ) { + val isExpandable = item.isExpandable + val linkUrl = item.link StatsListRowContainer( percentage = percentage, - modifier = if (onClick != null) { - Modifier.clickable(onClick = onClick) - } else { - Modifier + modifier = when { + isExpandable -> + Modifier.clickable { + onExpandToggle() + } + linkUrl != null -> + Modifier.clickable { + onUrlClick(linkUrl) + } + else -> Modifier } ) { Row( @@ -146,6 +162,12 @@ fun TagGroupRow( modifier = Modifier.width(4.dp) ) + } else if (linkUrl != null) { + StatsOpenLinkIcon() + Spacer( + modifier = + Modifier.width(4.dp) + ) } Text( text = item.name, @@ -175,6 +197,7 @@ fun TagGroupRow( @Composable fun ExpandedTagsSection( tags: List, + onUrlClick: (String) -> Unit = {}, startPadding: Dp = 24.dp ) { val lineColor = MaterialTheme.colorScheme.primary @@ -188,9 +211,20 @@ fun ExpandedTagsSection( ) ) { tags.forEachIndexed { index, tag -> + val linkUrl = tag.link + ?.takeIf { it.isNotBlank() } Row( modifier = Modifier .fillMaxWidth() + .then( + if (linkUrl != null) { + Modifier.clickable { + onUrlClick(linkUrl) + } + } else { + Modifier + } + ) .padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically @@ -219,6 +253,13 @@ fun ExpandedTagsSection( color = MaterialTheme.colorScheme .onSurface ) + if (linkUrl != null) { + Spacer( + modifier = + Modifier.width(4.dp) + ) + StatsOpenLinkIcon() + } } if (index < tags.lastIndex) { Spacer( diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt index 67c8dc3f8f9f..7e55829194cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesDetailActivity.kt @@ -41,10 +41,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import dagger.hilt.android.AndroidEntryPoint import org.wordpress.android.R +import org.wordpress.android.ui.ActivityNavigator import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.ui.newstats.components.StatsListHeader import org.wordpress.android.ui.newstats.util.ShimmerBox +import javax.inject.Inject private const val DETAIL_EXPANDED_START_PADDING = 52 private const val LOADING_SHIMMER_ITEM_COUNT = 10 @@ -56,6 +58,9 @@ class TagsAndCategoriesDetailActivity : TagsAndCategoriesDetailViewModel by viewModels() + @Inject + lateinit var activityNavigator: ActivityNavigator + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -70,7 +75,11 @@ class TagsAndCategoriesDetailActivity : onBackPressed = onBackPressedDispatcher ::onBackPressed, - onRetry = { viewModel.loadData() } + onRetry = { viewModel.loadData() }, + onUrlClick = { url -> + activityNavigator + .openInCustomTab(this, url) + } ) } } @@ -93,7 +102,8 @@ class TagsAndCategoriesDetailActivity : private fun TagsAndCategoriesDetailScreen( uiState: TagsAndCategoriesCardUiState, onBackPressed: () -> Unit, - onRetry: () -> Unit + onRetry: () -> Unit, + onUrlClick: (String) -> Unit = {} ) { Scaffold( topBar = { @@ -138,6 +148,7 @@ private fun TagsAndCategoriesDetailScreen( DetailLoadedContent( items = uiState.items, maxViews = uiState.maxViewsForBar, + onUrlClick = onUrlClick, modifier = Modifier .padding(contentPadding) ) @@ -209,6 +220,7 @@ private fun DetailEmptyContent( private fun DetailLoadedContent( items: List, maxViews: Long, + onUrlClick: (String) -> Unit, modifier: Modifier = Modifier ) { if (items.isEmpty()) { @@ -271,16 +283,11 @@ private fun DetailLoadedContent( item = item, percentage = percentage, position = index + 1, - isExpandable = item.isExpandable, isExpanded = isExpanded, - onClick = if (item.isExpandable) { - { - expandedGroups[index] = - !isExpanded - } - } else { - null - } + onExpandToggle = { + expandedGroups[index] = !isExpanded + }, + onUrlClick = onUrlClick ) if (item.isExpandable) { AnimatedVisibility( @@ -290,6 +297,7 @@ private fun DetailLoadedContent( ) { ExpandedTagsSection( tags = item.tags, + onUrlClick = onUrlClick, startPadding = DETAIL_EXPANDED_START_PADDING .dp diff --git a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt index 93620da3f08b..51c4fd90435e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesMapper.kt @@ -10,7 +10,8 @@ class TagsAndCategoriesMapper @Inject constructor() { val tagUiItems = group.tags.map { tag -> TagUiItem( name = tag.name, - tagType = tag.tagType + tagType = tag.tagType, + link = tag.link ) } TagGroupUiItem( diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 7a9dd76b2be3..945176bfb3cd 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1587,7 +1587,7 @@ Tag Expand Collapse - Open link + Open link Check back when you\'ve published your first post! It\'s been %1$s since %2$s was published. Get the ball rolling and increase your post views by sharing your post: It’s been %1$s since %2$s was published. Here’s how the post performed so far: diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt index 57f5d6da6220..faa59166f880 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedDetailFetcherTest.kt @@ -15,6 +15,7 @@ import org.wordpress.android.ui.newstats.repository.ClickItemData import org.wordpress.android.ui.newstats.repository.ClicksResult import org.wordpress.android.ui.newstats.repository.FileDownloadItemData import org.wordpress.android.ui.newstats.repository.FileDownloadsResult +import org.wordpress.android.ui.newstats.repository.MostViewedChildData import org.wordpress.android.ui.newstats.repository.MostViewedItemData import org.wordpress.android.ui.newstats.repository.MostViewedResult import org.wordpress.android.ui.newstats.repository.SearchTermItemData @@ -37,7 +38,7 @@ class MostViewedDetailFetcherTest : BaseUnitTest() { @Test fun `fetch initializes the repository with the access token`() = test { - whenever(statsRepository.fetchClicks(any(), any())).thenReturn(emptyClicksSuccess()) + whenever(statsRepository.fetchClicks(any(), any())).thenReturn(clicksSuccess()) fetcher.fetch(MostViewedDetailSource.CLICKS, SITE_ID, PERIOD, ACCESS_TOKEN) @@ -117,6 +118,23 @@ class MostViewedDetailFetcherTest : BaseUnitTest() { assertThat(success.totalValueChangePercent).isEqualTo(100.0) } + @Test + fun `fetch clicks carries the url and children onto the detail items`() = test { + val child = MostViewedChildData(name = "Child link", url = "child-url", views = 10L) + whenever(statsRepository.fetchClicks(any(), any())).thenReturn( + clicksSuccess( + ClickItemData("example.com", 50L, 20L, url = "link-url"), + ClickItemData("Grouped", 10L, 10L, children = listOf(child)) + ) + ) + + val result = fetcher.fetch(MostViewedDetailSource.CLICKS, SITE_ID, PERIOD, ACCESS_TOKEN) + + val items = (result as StatsCardFetchResult.Success).items + assertThat(items[0].url).isEqualTo("link-url") + assertThat(items[1].children.single().url).isEqualTo("child-url") + } + @Test fun `fetch clicks passes through the error message resId and auth flag`() = test { whenever(statsRepository.fetchClicks(any(), any())) @@ -226,8 +244,8 @@ class MostViewedDetailFetcherTest : BaseUnitTest() { } // endregion - private fun emptyClicksSuccess() = ClicksResult.Success( - items = emptyList(), + private fun clicksSuccess(vararg items: ClickItemData) = ClicksResult.Success( + items = items.toList(), totalClicks = 0L, totalClicksChange = 0L, totalClicksChangePercent = 0.0 diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt index 34d32fe2fd2d..506115726ba6 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/MostViewedViewModelTest.kt @@ -237,6 +237,26 @@ class MostViewedViewModelTest : BaseUnitTest() { assertThat(state.items).hasSize(10) } + @Test + fun `given a referrer with a url, when data loads, then the url reaches the card item`() = test { + val referrer = MostViewedItemData( + id = 1, + title = "google.com", + views = TEST_POST_VIEWS_1, + previousViews = TEST_POST_PREVIOUS_VIEWS_1, + isFirst = true, + url = TEST_REFERRER_URL + ) + whenever(statsRepository.fetchMostViewed(any(), any(), any())) + .thenReturn(createSuccessResult().copy(items = listOf(referrer))) + + initViewModel() + advanceUntilIdle() + + val state = viewModel.referrersUiState.value as MostViewedCardUiState.Loaded + assertThat(state.items[0].url).isEqualTo(TEST_REFERRER_URL) + } + @Test fun `when init, then both data sources are fetched in parallel`() = test { whenever(statsRepository.fetchMostViewed(any(), any(), any())) @@ -690,6 +710,8 @@ class MostViewedViewModelTest : BaseUnitTest() { private const val TEST_POST_TYPE_1 = "post" private const val TEST_POST_VIEWS_1 = 500L private const val TEST_POST_VIEWS_2 = 300L + private const val TEST_REFERRER_URL = "https://google.com/" + private const val TEST_POST_PREVIOUS_VIEWS_1 = 400L private const val TEST_POST_PREVIOUS_VIEWS_2 = 250L diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/StatsRowActionTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/StatsRowActionTest.kt new file mode 100644 index 000000000000..ab460be11797 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/mostviewed/StatsRowActionTest.kt @@ -0,0 +1,85 @@ +package org.wordpress.android.ui.newstats.mostviewed + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class StatsRowActionTest { + @Test + fun `given children, when the row is tapped, then it expands`() { + val action = statsRowAction(hasChildren = true, url = null, onItemClick = null) + + assertThat(action).isEqualTo(StatsRowAction.Expand) + } + + @Test + fun `given children and a url, then it still expands rather than navigating`() { + val action = statsRowAction(hasChildren = true, url = URL, onItemClick = null) + + assertThat(action).isEqualTo(StatsRowAction.Expand) + } + + @Test + fun `given children and an item click, then it still expands`() { + val action = statsRowAction(hasChildren = true, url = null, onItemClick = {}) + + assertThat(action).isEqualTo(StatsRowAction.Expand) + } + + @Test + fun `given no children and a url, then it opens the url`() { + val action = statsRowAction(hasChildren = false, url = URL, onItemClick = null) + + assertThat(action).isEqualTo(StatsRowAction.OpenUrl(URL)) + } + + /** + * Posts carry a url *and* an item click. The item click has to win, or a post row would open + * the post in a browser instead of its detail stats. + */ + @Test + fun `given both a url and an item click, then the item click wins`() { + var invoked = false + + val action = statsRowAction(hasChildren = false, url = URL, onItemClick = { invoked = true }) + + assertThat(action).isInstanceOf(StatsRowAction.Item::class.java) + (action as StatsRowAction.Item).onClick() + assertThat(invoked).isTrue() + } + + @Test + fun `given an item click and no url, then the item click is carried through`() { + var invoked = false + + val action = statsRowAction(hasChildren = false, url = null, onItemClick = { invoked = true }) + + assertThat(action).isInstanceOf(StatsRowAction.Item::class.java) + (action as StatsRowAction.Item).onClick() + assertThat(invoked).isTrue() + } + + @Test + fun `given nothing to act on, then the row is inert`() { + val action = statsRowAction(hasChildren = false, url = null, onItemClick = null) + + assertThat(action).isEqualTo(StatsRowAction.None) + } + + @Test + fun `given a blank url, then the row is inert`() { + val action = statsRowAction(hasChildren = false, url = " ", onItemClick = null) + + assertThat(action).isEqualTo(StatsRowAction.None) + } + + @Test + fun `given an empty url, then the row is inert`() { + val action = statsRowAction(hasChildren = false, url = "", onItemClick = null) + + assertThat(action).isEqualTo(StatsRowAction.None) + } + + companion object { + private const val URL = "https://example.com/" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryClicksTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryClicksTest.kt index d322b2b373ae..2c89ec797b29 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryClicksTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryClicksTest.kt @@ -14,6 +14,7 @@ import org.wordpress.android.BaseUnitTest import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.newstats.StatsPeriod +import org.wordpress.android.ui.newstats.datasource.ClickChildDataItem import org.wordpress.android.ui.newstats.datasource.ClickDataItem import org.wordpress.android.ui.newstats.datasource.ClicksDataResult import org.wordpress.android.ui.newstats.datasource.StatsDataSource @@ -64,6 +65,55 @@ class StatsRepositoryClicksTest : BaseUnitTest() { .isEqualTo(TEST_CLICK_CLICKS_2) } + @Test + fun `given a click with a url, when fetchClicks, then the url propagates`() = + test { + whenever(statsDataSource.fetchClicks(any(), any(), any())) + .thenReturn( + ClicksDataResult.Success(createClickItems()) + ) + + val result = repository.fetchClicks( + TEST_SITE_ID, StatsPeriod.Last7Days + ) + + val success = result as ClicksResult.Success + assertThat(success.items[0].url).isEqualTo(TEST_CLICK_URL_1) + assertThat(success.items[1].url).isEqualTo(TEST_CLICK_URL_2) + } + + @Test + fun `given a click group with children, when fetchClicks, then children propagate`() = + test { + val grouped = listOf( + ClickDataItem( + name = "Grouped source", + url = null, + clicks = 42, + children = listOf( + ClickChildDataItem( + name = "Child link", + url = TEST_CLICK_URL_1, + clicks = 42 + ) + ) + ) + ) + whenever(statsDataSource.fetchClicks(any(), any(), any())) + .thenReturn(ClicksDataResult.Success(grouped)) + + val result = repository.fetchClicks( + TEST_SITE_ID, StatsPeriod.Last7Days + ) + + val success = result as ClicksResult.Success + assertThat(success.items[0].url).isNull() + assertThat(success.items[0].children).hasSize(1) + assertThat(success.items[0].children[0].name).isEqualTo("Child link") + assertThat(success.items[0].children[0].url).isEqualTo(TEST_CLICK_URL_1) + assertThat(success.items[0].children[0].views).isEqualTo(42) + } + @Test fun `given successful response, when fetchClicks, then totalClicks is sum of item clicks`() = test { @@ -87,12 +137,12 @@ class StatsRepositoryClicksTest : BaseUnitTest() { fun `given current and previous data, when fetchClicks, then change is calculated correctly`() = test { val currentItems = listOf( - ClickDataItem("Link 1", 150), - ClickDataItem("Link 2", 100) + ClickDataItem(name = "Link 1", url = null, clicks = 150), + ClickDataItem(name = "Link 2", url = null, clicks = 100) ) val previousItems = listOf( - ClickDataItem("Link 1", 100), - ClickDataItem("Link 2", 100) + ClickDataItem(name = "Link 1", url = null, clicks = 100), + ClickDataItem(name = "Link 2", url = null, clicks = 100) ) whenever(statsDataSource.fetchClicks(any(), any(), any())) @@ -117,10 +167,10 @@ class StatsRepositoryClicksTest : BaseUnitTest() { fun `given item in both periods, when fetchClicks, then previousClicks is set correctly`() = test { val currentItems = listOf( - ClickDataItem("Link 1", 150) + ClickDataItem(name = "Link 1", url = null, clicks = 150) ) val previousItems = listOf( - ClickDataItem("Link 1", 100) + ClickDataItem(name = "Link 1", url = null, clicks = 100) ) whenever(statsDataSource.fetchClicks(any(), any(), any())) @@ -146,7 +196,7 @@ class StatsRepositoryClicksTest : BaseUnitTest() { fun `given new item not in previous period, when fetchClicks, then previousClicks is zero`() = test { val currentItems = listOf( - ClickDataItem("New Link", 100) + ClickDataItem(name = "New Link", url = null, clicks = 100) ) val previousItems = emptyList() @@ -175,7 +225,7 @@ class StatsRepositoryClicksTest : BaseUnitTest() { fun `given previous fetch fails, when fetchClicks, then previousClicks defaults to zero`() = test { val currentItems = listOf( - ClickDataItem("Link 1", 100) + ClickDataItem(name = "Link 1", url = null, clicks = 100) ) whenever(statsDataSource.fetchClicks(any(), any(), any())) @@ -370,10 +420,12 @@ class StatsRepositoryClicksTest : BaseUnitTest() { private fun createClickItems() = listOf( ClickDataItem( name = TEST_CLICK_NAME_1, + url = TEST_CLICK_URL_1, clicks = TEST_CLICK_CLICKS_1 ), ClickDataItem( name = TEST_CLICK_NAME_2, + url = TEST_CLICK_URL_2, clicks = TEST_CLICK_CLICKS_2 ) ) @@ -383,6 +435,8 @@ class StatsRepositoryClicksTest : BaseUnitTest() { private const val TEST_CLICK_NAME_1 = "example.com" private const val TEST_CLICK_NAME_2 = "wordpress.org" + private const val TEST_CLICK_URL_1 = "https://example.com/" + private const val TEST_CLICK_URL_2 = "https://wordpress.org/" private const val TEST_CLICK_CLICKS_1 = 500L private const val TEST_CLICK_CLICKS_2 = 300L } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt index 55784967b85e..5b8f60e01565 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryReferrersTest.kt @@ -64,6 +64,7 @@ class StatsRepositoryReferrersTest : BaseUnitTest() { val referrersData = listOf( ReferrerDataItem( name = "Search Engines", + url = null, views = 23, children = listOf( ReferrerChildDataItem(name = "Google Search", url = "http://google.com/", views = 23) @@ -87,6 +88,22 @@ class StatsRepositoryReferrersTest : BaseUnitTest() { assertThat(success.items[0].children[0].views).isEqualTo(23) } + @Test + fun `given referrer with a url, when fetchMostViewed with REFERRERS, then the url propagates`() = test { + whenever(statsDataSource.fetchReferrers(any(), any(), any())) + .thenReturn(ReferrersDataResult.Success(createReferrersData())) + + val result = repository.fetchMostViewed( + TEST_SITE_ID, + StatsPeriod.Last7Days, + MostViewedDataSource.REFERRERS + ) + + val success = result as MostViewedResult.Success + assertThat(success.items[0].url).isEqualTo(TEST_REFERRER_URL_1) + assertThat(success.items[1].url).isEqualTo(TEST_REFERRER_URL_2) + } + @Test fun `when fetchMostViewed with REFERRERS, then card-sized max is requested`() = test { whenever(statsDataSource.fetchReferrers(any(), any(), any())) @@ -125,8 +142,8 @@ class StatsRepositoryReferrersTest : BaseUnitTest() { } private fun createReferrersData() = listOf( - ReferrerDataItem(name = TEST_REFERRER_NAME_1, views = TEST_REFERRER_VIEWS_1), - ReferrerDataItem(name = TEST_REFERRER_NAME_2, views = TEST_REFERRER_VIEWS_2) + ReferrerDataItem(name = TEST_REFERRER_NAME_1, url = TEST_REFERRER_URL_1, views = TEST_REFERRER_VIEWS_1), + ReferrerDataItem(name = TEST_REFERRER_NAME_2, url = TEST_REFERRER_URL_2, views = TEST_REFERRER_VIEWS_2) ) companion object { @@ -139,6 +156,8 @@ class StatsRepositoryReferrersTest : BaseUnitTest() { private const val TEST_REFERRER_NAME_1 = "google.com" private const val TEST_REFERRER_NAME_2 = "twitter.com" + private const val TEST_REFERRER_URL_1 = "https://google.com/" + private const val TEST_REFERRER_URL_2 = "https://twitter.com/" private const val TEST_REFERRER_VIEWS_1 = 200L private const val TEST_REFERRER_VIEWS_2 = 150L } diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt index fb89e4c22de9..27cdc1bb6d82 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/repository/StatsRepositoryTagsTest.kt @@ -211,7 +211,8 @@ class StatsRepositoryTagsTest : BaseUnitTest() { tags = listOf( TagData( tagType = "tag", - name = "Alpha" + name = "Alpha", + link = ALPHA_LINK ), TagData( tagType = "category", @@ -243,6 +244,8 @@ class StatsRepositoryTagsTest : BaseUnitTest() { .isEqualTo("Alpha") assertThat(group.tags[1].name) .isEqualTo("Beta") + assertThat(group.tags[0].link) + .isEqualTo(ALPHA_LINK) } private fun createTestTagsData() = StatsTagsData( @@ -270,6 +273,8 @@ class StatsRepositoryTagsTest : BaseUnitTest() { companion object { private const val TEST_SITE_ID = 123L + private const val ALPHA_LINK = + "https://example.com/tag/alpha" private const val TEST_CATEGORY_NAME = "Uncategorized" private const val TEST_CATEGORY_VIEWS = 83L diff --git a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt index f700af4db04c..f489a045b982 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/newstats/tagsandcategories/TagsAndCategoriesViewModelTest.kt @@ -211,6 +211,65 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { .isEqualTo(TEST_TAG_VIEWS) } + @Test + fun `when a group holds one tag, then the group exposes that tag's link`() = + test { + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn(createSuccessResult()) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + assertThat(state.items[0].tags[0].link) + .isEqualTo(TEST_CATEGORY_LINK) + assertThat(state.items[0].link) + .isEqualTo(TEST_CATEGORY_LINK) + } + + @Test + fun `when a group holds several tags, then the group has no link of its own`() = + test { + val group = TagGroupData( + tags = listOf( + TagData( + tagType = "tag", + name = "Tag1", + link = "https://example.com/tag1" + ), + TagData( + tagType = "tag", + name = "Tag2", + link = "https://example.com/tag2" + ) + ), + views = 50 + ) + whenever( + statsTagsUseCase(any(), any(), any()) + ).thenReturn( + TagsResult.Success( + StatsTagsData( + tagGroups = listOf(group) + ) + ) + ) + + initViewModel() + viewModel.loadData() + advanceUntilIdle() + + val state = viewModel.uiState.value + as TagsAndCategoriesCardUiState.Loaded + // The row expands instead of navigating, but each child keeps its own link. + assertThat(state.items[0].link).isNull() + assertThat(state.items[0].tags[1].link) + .isEqualTo("https://example.com/tag2") + } + @Test fun `when fetch succeeds, then maxViewsForBar is first item views`() = test { @@ -537,7 +596,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { tags = listOf( TagData( tagType = "category", - name = TEST_CATEGORY_NAME + name = TEST_CATEGORY_NAME, + link = TEST_CATEGORY_LINK ) ), views = TEST_CATEGORY_VIEWS @@ -566,6 +626,8 @@ class TagsAndCategoriesViewModelTest : BaseUnitTest() { private const val TEST_CATEGORY_NAME = "Uncategorized" + private const val TEST_CATEGORY_LINK = + "https://example.com/category/uncategorized" private const val TEST_CATEGORY_VIEWS = 83L private const val TEST_TAG_NAME = "snaps" private const val TEST_TAG_VIEWS = 15L