Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.woocommerce.android.ui.products.filter

import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import javax.inject.Inject

/**
* Encodes/decodes a [ProductFilterResult] to and from the opaque `payload` string persisted in the
* filter history table.
*
* The payload is a JSON serialization of the selected slugs; Gson omits null fields, so logically
* identical selections produce identical JSON and dedup reliably. The category name is only persisted
* when a category id is actually selected, so clearing the category to "Any" can't leak a stale name
* into the payload (which would break dedup). Decoding tolerates missing/unknown fields (all data-class
* fields default to null).
*/
class ProductFilterHistoryMapper @Inject constructor(
private val gson: Gson
) {
fun toPayload(filter: ProductFilterResult): String = gson.toJson(filter.toData())

fun fromPayload(payload: String): ProductFilterResult? =
runCatching { gson.fromJson(payload, ProductFilterHistoryData::class.java) }.getOrNull()?.toResult()

private fun ProductFilterResult.toData() = ProductFilterHistoryData(
stockStatus = stockStatus,
productStatus = productStatus,
productType = productType,
productCategory = productCategory,
productCategoryName = productCategoryName?.takeIf { productCategory != null }
)

private fun ProductFilterHistoryData.toResult() = ProductFilterResult(
stockStatus = stockStatus,
productType = productType,
productStatus = productStatus,
productCategory = productCategory,
productCategoryName = productCategoryName
)

data class ProductFilterHistoryData(
@SerializedName("stock_status") val stockStatus: String? = null,
@SerializedName("product_status") val productStatus: String? = null,
@SerializedName("product_type") val productType: String? = null,
@SerializedName("product_category") val productCategory: String? = null,
@SerializedName("product_category_name") val productCategoryName: String? = null
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@ import com.woocommerce.android.R
import com.woocommerce.android.analytics.AnalyticsEvent
import com.woocommerce.android.analytics.AnalyticsTracker
import com.woocommerce.android.databinding.FragmentProductFilterListBinding
import com.woocommerce.android.extensions.handleResult
import com.woocommerce.android.extensions.navigateBackWithResult
import com.woocommerce.android.extensions.navigateSafely
import com.woocommerce.android.extensions.takeIfNotEqualTo
import com.woocommerce.android.ui.base.BaseFragment
import com.woocommerce.android.ui.filters.FilterHistoryType
import com.woocommerce.android.ui.filters.FilterHistoryViewModel
import com.woocommerce.android.ui.filters.SavedFilter
import com.woocommerce.android.ui.main.AppBarStatus
import com.woocommerce.android.ui.main.MainActivity
import com.woocommerce.android.ui.products.filter.ProductFilterListViewModel.OpenFilterHistory
import com.woocommerce.android.ui.products.list.ProductListFragment
import com.woocommerce.android.viewmodel.MultiLiveEvent
import dagger.hilt.android.AndroidEntryPoint
Expand Down Expand Up @@ -84,10 +89,12 @@ class ProductFilterListFragment :

override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_clear, menu)
inflater.inflate(R.menu.menu_filter_history, menu)
}

override fun onPrepareMenu(menu: Menu) {
updateClearButtonVisibility(menu.findItem(R.id.menu_clear))
menu.findItem(R.id.menu_filter_history).isVisible = viewModel.isFilterHistoryEnabled
}

override fun onMenuItemSelected(item: MenuItem): Boolean {
Expand All @@ -98,6 +105,12 @@ class ProductFilterListFragment :
updateClearButtonVisibility(item)
true
}

R.id.menu_filter_history -> {
viewModel.onFilterHistoryButtonClicked()
true
}

else -> false
}
}
Expand All @@ -116,13 +129,25 @@ class ProductFilterListFragment :
is MultiLiveEvent.Event.ExitWithResult<*> -> {
navigateBackWithResult(ProductListFragment.PRODUCT_FILTER_RESULT_KEY, event.data)
}
is OpenFilterHistory -> navigateToFilterHistory()
else -> event.isHandled = false
}
}

handleResult<SavedFilter>(FilterHistoryViewModel.FILTER_HISTORY_RESULT_KEY) {
viewModel.onPastFilterSelected(it)
}

viewModel.loadFilters()
}

private fun navigateToFilterHistory() {
findNavController().navigateSafely(
ProductFilterListFragmentDirections
.actionProductFilterListFragmentToFilterHistoryFragment(FilterHistoryType.PRODUCTS)
)
}

private fun showProductFilterList(productFilterList: List<ProductFilterListViewModel.FilterListItemUiModel>) {
productFilterListAdapter.filterList = productFilterList
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ import com.woocommerce.android.model.sortCategories
import com.woocommerce.android.tools.NetworkStatus
import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.ui.common.PluginRepository
import com.woocommerce.android.ui.filters.SavedFilter
import com.woocommerce.android.ui.products.ProductStatus
import com.woocommerce.android.ui.products.ProductStockStatus
import com.woocommerce.android.ui.products.ProductType
import com.woocommerce.android.ui.products.categories.ProductCategoriesRepository
import com.woocommerce.android.ui.products.filter.ProductFilterListViewModel.FilterListOptionItemUiModel.DefaultFilterListOptionItemUiModel
import com.woocommerce.android.util.FeatureFlag
import com.woocommerce.android.util.FeatureFlagRepository
import com.woocommerce.android.viewmodel.LiveDataDelegate
import com.woocommerce.android.viewmodel.MultiLiveEvent
import com.woocommerce.android.viewmodel.ResourceProvider
Expand Down Expand Up @@ -50,12 +53,17 @@ class ProductFilterListViewModel @Inject constructor(
private val pluginRepository: PluginRepository,
private val selectedSite: SelectedSite,
private val analyticsTracker: AnalyticsTrackerWrapper,
private val saveProductFilterToHistory: SaveProductFilterToHistory,
private val productFilterHistoryMapper: ProductFilterHistoryMapper,
featureFlagRepository: FeatureFlagRepository,
) : ScopedViewModel(savedState) {
companion object {
private const val KEY_PRODUCT_FILTER_OPTIONS = "key_product_filter_options"
private const val KEY_PRODUCT_FILTER_SELECTED_CATEGORY_NAME = "key_product_filter_selected_category_name"
}

val isFilterHistoryEnabled: Boolean = featureFlagRepository.isEnabled(FeatureFlag.FILTER_HISTORY)

private val arguments: ProductFilterListFragmentArgs by savedState.navArgs()

private var pluginsInformation: Map<String, WooPlugin> = HashMap()
Expand Down Expand Up @@ -314,9 +322,31 @@ class ProductFilterListViewModel @Inject constructor(
productCategory = getFilterByProductCategory(),
productCategoryName = selectedCategoryName
)
saveProductFilterToHistory(result)
triggerEvent(MultiLiveEvent.Event.ExitWithResult(result))
}

fun onFilterHistoryButtonClicked() {
analyticsTracker.track(
AnalyticsEvent.FILTER_HISTORY_BUTTON_TAPPED,
mapOf(AnalyticsTracker.KEY_SOURCE to AnalyticsTracker.VALUE_FILTER_HISTORY_SOURCE_PRODUCTS)
)
triggerEvent(OpenFilterHistory)
}

fun onPastFilterSelected(savedFilter: SavedFilter) {
val result = productFilterHistoryMapper.fromPayload(savedFilter.payload) ?: return
productFilterOptions.clear()
result.stockStatus?.let { productFilterOptions[STOCK_STATUS] = it }
result.productStatus?.let { productFilterOptions[STATUS] = it }
result.productType?.let { productFilterOptions[TYPE] = it }
result.productCategory?.let { productFilterOptions[CATEGORY] = it }
selectedCategoryName = result.productCategoryName
savedState[KEY_PRODUCT_FILTER_OPTIONS] = productFilterOptions
savedState[KEY_PRODUCT_FILTER_SELECTED_CATEGORY_NAME] = selectedCategoryName
loadFilters()
}

private fun buildFilterListItemUiModel(): List<FilterListItemUiModel> {
val filterListItems = mutableListOf<FilterListItemUiModel>()
filterListItems.add(
Expand Down Expand Up @@ -450,6 +480,8 @@ class ProductFilterListViewModel @Inject constructor(
}?.filterOptionListItems = categoryOptions
}

object OpenFilterHistory : MultiLiveEvent.Event()

@Parcelize
data class ProductFilterListViewState(
val screenTitle: String? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.woocommerce.android.ui.products.filter

import androidx.annotation.StringRes
import com.woocommerce.android.di.AppCoroutineScope
import com.woocommerce.android.ui.filters.FilterHistoryRepository
import com.woocommerce.android.ui.filters.FilterHistoryType
import com.woocommerce.android.ui.products.ProductStatus
import com.woocommerce.android.ui.products.ProductStockStatus
import com.woocommerce.android.ui.products.ProductType
import com.woocommerce.android.util.FeatureFlag
import com.woocommerce.android.util.FeatureFlagRepository
import com.woocommerce.android.util.WooLog
import com.woocommerce.android.viewmodel.ResourceProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject

/**
* Persists the given product filter selection to the filter history.
*
* Fire-and-forget on the application scope so it survives the filter screen being dismissed, letting
* callers navigate away immediately without awaiting the DB write. Failures are logged rather than
* propagated — persisting history is best-effort and must never crash the app.
*
* Owns the human-readable label resolution (slug → localized label) so the [ProductFilterHistoryMapper]
* stays a pure payload codec, matching the order-list use case.
*/
class SaveProductFilterToHistory @Inject constructor(
private val featureFlagRepository: FeatureFlagRepository,
private val filterHistoryRepository: FilterHistoryRepository,
private val productFilterHistoryMapper: ProductFilterHistoryMapper,
private val resourceProvider: ResourceProvider,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope
) {
operator fun invoke(filter: ProductFilterResult) {
if (!featureFlagRepository.isEnabled(FeatureFlag.FILTER_HISTORY)) return
if (!filter.hasSelection()) return
appCoroutineScope.launch {
runCatching {
filterHistoryRepository.save(
type = FilterHistoryType.PRODUCTS,
payload = productFilterHistoryMapper.toPayload(filter),
readableString = buildReadableString(filter)
)
}.onFailure { WooLog.e(WooLog.T.PRODUCTS, "Failed to save product filter to history", it) }
}
}

private fun buildReadableString(filter: ProductFilterResult): String =
listOfNotNull(
filter.stockStatus?.let { label(ProductStockStatus.fromString(it).stringResource) },
filter.productStatus?.let { status -> ProductStatus.fromString(status)?.let { label(it.stringResource) } },
filter.productType?.let { label(ProductType.fromString(it).stringResource) },
filter.productCategoryName?.takeIf { filter.productCategory != null }
).joinToString(separator = ", ")

private fun label(@StringRes resId: Int): String? =
resId.takeIf { it != 0 }?.let { resourceProvider.getString(it) }

private fun ProductFilterResult.hasSelection(): Boolean =
stockStatus != null || productStatus != null || productType != null || productCategory != null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.woocommerce.android.ui.products.filter

import com.google.gson.Gson
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

class ProductFilterHistoryMapperTest {
private val sut = ProductFilterHistoryMapper(Gson())

@Test
fun `given a full selection, when encoding then decoding, then it round-trips`() {
val filter = ProductFilterResult(
stockStatus = "instock",
productType = "simple",
productStatus = "draft",
productCategory = "5",
productCategoryName = "Shoes"
)

assertThat(sut.fromPayload(sut.toPayload(filter))).isEqualTo(filter)
}

@Test
fun `given identical selections, when encoded, then payloads are identical`() {
val a = ProductFilterResult("instock", null, "draft", null, null)
val b = ProductFilterResult("instock", null, "draft", null, null)

assertThat(sut.toPayload(a)).isEqualTo(sut.toPayload(b))
}

@Test
fun `given an invalid payload, when decoding, then null is returned`() {
assertThat(sut.fromPayload("not-json")).isNull()
}

@Test
fun `given an empty json payload, when decoding, then a filter with no selection is returned`() {
assertThat(sut.fromPayload("{}")).isEqualTo(ProductFilterResult(null, null, null, null, null))
}

@Test
fun `given a category name without a category id, when encoding, then the name is dropped`() {
val withStaleName = ProductFilterResult("instock", null, null, null, "Any")
val withoutName = ProductFilterResult("instock", null, null, null, null)

// The stale name must not leak into the payload, otherwise it breaks dedup against the same
// stock-only filter saved from another path.
assertThat(sut.toPayload(withStaleName)).isEqualTo(sut.toPayload(withoutName))
assertThat(sut.fromPayload(sut.toPayload(withStaleName))?.productCategoryName).isNull()
}

@Test
fun `given a category name with a category id, when encoding, then the name is kept`() {
val filter = ProductFilterResult(null, null, null, "5", "Shoes")

assertThat(sut.fromPayload(sut.toPayload(filter))?.productCategoryName).isEqualTo("Shoes")
}
}
Loading
Loading