Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,17 @@ fun AddRemoveTagsScreen(
},
bottomBar = {
val isLoading = addRemoveTagsViewModel.isLoading.collectAsState().value
val tags = addRemoveTagsViewModel.suggestedTags.collectAsState()
val tags = addRemoveTagsViewModel.suggestedTags
Column(
modifier = Modifier.background(colorsScheme().background)
) {
if (tags.value.isNotEmpty()) {
if (tags.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = dimensions().spacing16x, end = dimensions().spacing16x)
) {
tags.value.forEach { tag ->
tags.forEach { tag ->
item {
WireFilterChip(
modifier = Modifier.padding(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package com.wire.android.feature.cells.ui.tags

import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
Expand All @@ -27,16 +30,13 @@ import com.wire.android.ui.common.ActionsViewModel
import com.wire.kalium.cells.domain.usecase.GetAllTagsUseCase
import com.wire.kalium.cells.domain.usecase.RemoveNodeTagsUseCase
import com.wire.kalium.cells.domain.usecase.UpdateNodeTagsUseCase
import com.wire.kalium.common.functional.getOrElse
import com.wire.kalium.common.functional.onFailure
import com.wire.kalium.common.functional.onSuccess
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
Expand All @@ -55,70 +55,77 @@ class AddRemoveTagsViewModel @Inject constructor(

val tagsTextState = TextFieldState()

private val allTags: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet())

val initialTags: Set<String> = navArgs.tags.toSet()
private val _addedTags: MutableStateFlow<Set<String>> = MutableStateFlow(navArgs.tags.toSet())
internal val addedTags = _addedTags.asStateFlow()

val disallowedChars = listOf(",", ";", "/", "\\", "\"", "\'", "<", ">")

private val _suggestedTags = MutableStateFlow<Set<String>>(emptySet())
internal val suggestedTags =
allTags.combine(addedTags) { all, added ->
all.filter { it !in added }.toSet().sorted()
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptySet())
var allTags: Set<String> = emptySet()
private set

val addedTags: MutableStateFlow<Set<String>> = MutableStateFlow(navArgs.tags.toSet())

var suggestedTags by mutableStateOf<Set<String>>(emptySet())
private set

init {
viewModelScope.launch {
getAllTagsUseCase().onSuccess { tags ->
allTags.update { tags }
}
launch {
snapshotFlow { tagsTextState.text.toString() }
.debounce(TYPING_DEBOUNCE_TIME)
.collectLatest { query ->
val filtered = if (query.isBlank()) {
allTags.value
} else {
allTags.value.filter { it.contains(query, ignoreCase = true) }.toSet()
}
_suggestedTags.value = filtered
}
}
allTags = getAllTagsUseCase().getOrElse { emptySet() }
updateSuggestions("") // initial state
}

viewModelScope.launch {
snapshotFlow { tagsTextState.text.toString() }
.debounce(TYPING_DEBOUNCE_TIME)
.collectLatest {
onQueryChanged(it)
}
}
}

fun onQueryChanged(query: String) {
updateSuggestions(query)
}

private fun updateSuggestions(query: String) {
suggestedTags = allTags
.asSequence()
.filter { query.isBlank() || it.contains(query, ignoreCase = true) }
.filter { it !in addedTags.value }
.toSet()
}

fun isValidTag(): Boolean = disallowedChars.none {
it in tagsTextState.text
} && tagsTextState.text.length in ALLOWED_LENGTH

fun addTag(tag: String) {
tag.trim().let { newTag ->
if (newTag.isNotBlank() && newTag !in _addedTags.value) {
_addedTags.update { it + newTag }
if (newTag.isNotBlank() && newTag !in addedTags.value) {
addedTags.update { it + newTag }
updateSuggestions("")
tagsTextState.clearText()
}
}
}

fun removeTag(tag: String) {
_addedTags.update { it - tag }
addedTags.update { it - tag }
updateSuggestions("")
}

fun removeLastTag() {
_addedTags.value.lastOrNull()?.let { lastTag ->
addedTags.value.lastOrNull()?.let { lastTag ->
removeTag(lastTag)
}
}

fun updateTags() {
viewModelScope.launch {
isLoading.value = true
val result = if (_addedTags.value.isEmpty()) {
val result = if (addedTags.value.isEmpty()) {
removeNodeTagsUseCase(navArgs.uuid)
} else {
updateNodeTagsUseCase(navArgs.uuid, _addedTags.value)
updateNodeTagsUseCase(navArgs.uuid, addedTags.value)
}

result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import io.mockk.every
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
Expand All @@ -47,7 +47,7 @@ import org.junit.jupiter.api.Test

class AddRemoveTagsViewModelTest {

private val dispatcher = UnconfinedTestDispatcher()
private val dispatcher = StandardTestDispatcher()

@BeforeEach
fun beforeEach() {
Expand All @@ -60,7 +60,7 @@ class AddRemoveTagsViewModelTest {
}

@Test
fun `given a new valid tag, when addTag is called, then tag is added and suggestions updated`() = runTest {
fun `given a new valid tag, when addTag is called, then tag is added`() = runTest {
val (_, viewModel) = Arrangement()
.withGetAllTagsUseCaseReturning(Either.Right(setOf()))
.arrange()
Expand All @@ -70,11 +70,6 @@ class AddRemoveTagsViewModelTest {

val addedTags = viewModel.addedTags.first()
assertTrue(addedTags.contains(newTag))

val suggestedTags = viewModel.suggestedTags.first()
assertFalse(suggestedTags.contains(newTag))

assertEquals("", viewModel.tagsTextState.text)
}

@Test
Expand Down Expand Up @@ -113,11 +108,10 @@ class AddRemoveTagsViewModelTest {
.withGetAllTagsUseCaseReturning(Either.Right(setOf(tagInSuggestions)))
.arrange()

viewModel.suggestedTags.test {
assertTrue(expectMostRecentItem().contains(tagInSuggestions))
viewModel.addTag(tagInSuggestions)
assertFalse(expectMostRecentItem().contains(tagInSuggestions))
}
advanceUntilIdle()
assertTrue(viewModel.suggestedTags.contains(tagInSuggestions))
viewModel.addTag(tagInSuggestions)
assertFalse(viewModel.suggestedTags.contains(tagInSuggestions))
}

@Test
Expand Down Expand Up @@ -299,15 +293,6 @@ class AddRemoveTagsViewModelTest {
every { savedStateHandle.get<ArrayList<String>>("tags") } returns ArrayList()
}

private val viewModel by lazy {
AddRemoveTagsViewModel(
savedStateHandle = savedStateHandle,
getAllTagsUseCase = getAllTagsUseCase,
updateNodeTagsUseCase = updateNodeTagsUseCase,
removeNodeTagsUseCase = removeNodeTagsUseCase,
)
}

fun withGetAllTagsUseCaseReturning(result: Either<CoreFailure, Set<String>>) = apply {
coEvery { getAllTagsUseCase() } returns result
}
Expand All @@ -320,6 +305,16 @@ class AddRemoveTagsViewModelTest {
coEvery { removeNodeTagsUseCase(any()) } returns result
}

fun arrange() = this to viewModel
fun arrange(): Pair<Arrangement, AddRemoveTagsViewModel> {
// Create a new ViewModel instance every time arrange() is called.
// This prevents state from leaking between tests.
val viewModel = AddRemoveTagsViewModel(
savedStateHandle = savedStateHandle,
getAllTagsUseCase = getAllTagsUseCase,
updateNodeTagsUseCase = updateNodeTagsUseCase,
removeNodeTagsUseCase = removeNodeTagsUseCase,
)
return this to viewModel
}
}
}