diff --git a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt index 399d55270269..932850f1f126 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsDataViewActivity.kt @@ -7,45 +7,63 @@ import android.os.Bundle import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch import org.wordpress.android.R import org.wordpress.android.fluxc.utils.AppLogWrapper import org.wordpress.android.ui.compose.theme.AppThemeM3 import org.wordpress.android.ui.dataview.DataViewScreen import org.wordpress.android.ui.main.BaseAppCompatActivity import org.wordpress.android.util.AppLog +import org.wordpress.android.util.ToastUtils import uniffi.wp_api.AnyTermWithEditContext import javax.inject.Inject @@ -71,8 +89,6 @@ class TermsDataViewActivity : BaseAppCompatActivity() { return } - viewModel.initialize(taxonomySlug, isHierarchical) - composeView = ComposeView(this) setContentView( composeView.apply { @@ -85,35 +101,72 @@ class TermsDataViewActivity : BaseAppCompatActivity() { } } ) - } - private enum class TermScreen { - List, - Detail + viewModel.initialize(taxonomySlug, isHierarchical) + lifecycleScope.launch { + viewModel.uiEvent.filterNotNull().collect { event -> + when (event) { + is UiEvent.ShowError -> ToastUtils.showToast( + this@TermsDataViewActivity, + event.messageRes, + ToastUtils.Duration.LONG + ) + } + viewModel.consumeUIEvent() + } + } } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NavigableContent(taxonomyName: String) { navController = rememberNavController() - val listTitle = taxonomyName - val titleState = remember { mutableStateOf(listTitle) } + + LaunchedEffect(navController) { + viewModel.setNavController(navController) + } + + val termDetailState by viewModel.termDetailState.collectAsState() + + // Observe navigation changes to trigger recomposition + val currentBackStackEntry by navController.currentBackStackEntryFlow + .collectAsState(initial = navController.currentBackStackEntry) + val currentRoute = currentBackStackEntry?.destination?.route + + LaunchedEffect(termDetailState) { + if (termDetailState == null && currentRoute != TermScreen.List.name) { + navController.navigateUp() + } + } AppThemeM3 { Scaffold( topBar = { TopAppBar( - title = { Text(titleState.value) }, + title = { Text(taxonomyName) }, navigationIcon = { IconButton(onClick = { if (navController.previousBackStackEntry != null) { - navController.navigateUp() + viewModel.navigateBack() } else { finish() } }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } + }, + actions = { + // Show the add button only on the List screen + if (currentRoute == TermScreen.List.name) { + IconButton(onClick = { + viewModel.navigateToCreateTerm() + }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_term) + ) + } + } } ) }, @@ -123,26 +176,26 @@ class TermsDataViewActivity : BaseAppCompatActivity() { startDestination = TermScreen.List.name ) { composable(route = TermScreen.List.name) { - titleState.value = listTitle ShowListScreen( - navController, modifier = Modifier.padding(contentPadding) ) } composable(route = TermScreen.Detail.name) { - navController.previousBackStackEntry?.savedStateHandle?.let { handle -> - val termId = handle.get(KEY_TERM_ID) - if (termId != null) { - viewModel.getTerm(termId)?.let { term -> - titleState.value = term.name - ShowTermDetailScreen( - allTerms = viewModel.getAllTerms(), - term = term, - modifier = Modifier.padding(contentPadding) - ) - } - } + termDetailState?.let { state -> + ShowTermDetailScreen( + state = state, + modifier = Modifier.padding(contentPadding) + ) + } + } + + composable(route = TermScreen.Create.name) { + termDetailState?.let { state -> + ShowTermDetailScreen( + state = state, + modifier = Modifier.padding(contentPadding) + ) } } } @@ -152,7 +205,6 @@ class TermsDataViewActivity : BaseAppCompatActivity() { @Composable private fun ShowListScreen( - navController: NavHostController, modifier: Modifier ) { DataViewScreen( @@ -171,11 +223,7 @@ class TermsDataViewActivity : BaseAppCompatActivity() { onItemClick = { item -> viewModel.onItemClick(item) (item.data as? AnyTermWithEditContext)?.let { term -> - navController.currentBackStackEntry?.savedStateHandle?.set( - key = KEY_TERM_ID, - value = term.id - ) - navController.navigate(route = TermScreen.Detail.name) + viewModel.navigateToTermDetail(term.id) } }, onFilterClick = { filter -> @@ -194,10 +242,12 @@ class TermsDataViewActivity : BaseAppCompatActivity() { @Composable private fun ShowTermDetailScreen( - allTerms: List, - term: AnyTermWithEditContext, + state: TermDetailUiState, modifier: Modifier ) { + val isSaving by viewModel.isSaving.collectAsState() + val isDeleting by viewModel.isDeleting.collectAsState() + Column( modifier = modifier .fillMaxSize() @@ -205,12 +255,71 @@ class TermsDataViewActivity : BaseAppCompatActivity() { .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - TermDetailsCard(allTerms, term) + TermDetailsCard(state = state) + + Button( + onClick = { viewModel.saveTerm() }, + modifier = Modifier.fillMaxWidth(), + enabled = !isSaving && !isDeleting + ) { + if (isSaving) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary + ) + } else { + Text(stringResource(R.string.save)) + } + } + + // Only show delete button if editing existing term (termId != 0) + if (state.termId != 0L) { + DeleteTermButton( + isDeleting, + onClick = { + if (!isDeleting) { + showDeleteTermConfirmation(state.termId, state.name) + } + } + ) + } } } @Composable - private fun TermDetailsCard(allTerms: List, term: AnyTermWithEditContext) { + private fun DeleteTermButton( + isDeleting: Boolean, + onClick: () -> Unit, + ) { + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = androidx.compose.ui.graphics.Color.Transparent, + contentColor = MaterialTheme.colorScheme.error + ), + elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.delete), + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + if (isDeleting) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.error + ) + } else { + Text( + text = stringResource(R.string.delete), + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + + @Composable + private fun TermDetailsCard(state: TermDetailUiState) { Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), @@ -224,32 +333,94 @@ class TermsDataViewActivity : BaseAppCompatActivity() { .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - DetailRow( + EditableDetailField( label = stringResource(R.string.term_name_label), - value = term.name + value = state.name, + onValueChange = { viewModel.updateTermName(it) } ) - DetailRow( + EditableDetailField( label = stringResource(R.string.term_slug_label), - value = term.slug + value = state.slug, + onValueChange = { viewModel.updateTermSlug(it) } ) - DetailRow( + EditableDetailField( label = stringResource(R.string.term_description_label), - value = term.description + value = state.description, + onValueChange = { viewModel.updateTermDescription(it) }, + singleLine = false ) - DetailRow( - label = stringResource(R.string.term_count_label), - value = term.count.toString() + if (!state.availableParents.isNullOrEmpty() && state.parentId != null) { + ParentDropdownField( + label = stringResource(R.string.term_parent_label), + availableParents = state.availableParents, + selectedParentId = state.parentId, + onParentIdChange = { viewModel.updateTermParent(it) } + ) + } + } + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + private fun ParentDropdownField( + label: String, + availableParents: List, + selectedParentId: Long, + onParentIdChange: (Long) -> Unit + ) { + var expanded by remember { mutableStateOf(false) } + val selectedParentName = availableParents.firstOrNull { it.id == selectedParentId }?.name + ?: stringResource(R.string.term_parent_none) + + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp) + ) + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it } + ) { + OutlinedTextField( + value = selectedParentName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(androidx.compose.material3.ExposedDropdownMenuAnchorType.PrimaryNotEditable), + textStyle = MaterialTheme.typography.bodyMedium ) - term.parent?.let { parentId -> - val parentName = allTerms.firstOrNull { it.id == parentId }?.name - parentName?.let { - DetailRow( - label = stringResource(R.string.term_parent_label), - value = parentName + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.term_parent_none)) }, + onClick = { + onParentIdChange(0L) + expanded = false + } + ) + + availableParents.forEach { parent -> + DropdownMenuItem( + text = { Text(parent.name) }, + onClick = { + onParentIdChange(parent.id) + expanded = false + } ) } } @@ -257,28 +428,42 @@ class TermsDataViewActivity : BaseAppCompatActivity() { } } + private fun showDeleteTermConfirmation(termId: Long, termName: String) { + MaterialAlertDialogBuilder(this).also { builder -> + builder.setTitle(R.string.term_delete_confirmation_title) + builder.setMessage(getString(R.string.term_delete_confirmation_message, termName)) + builder.setPositiveButton(R.string.delete) { _, _ -> + viewModel.deleteTerm(termId) + } + builder.setNegativeButton(R.string.cancel, null) + builder.show() + } + } + @Composable - private fun DetailRow( + private fun EditableDetailField( label: String, - value: String + value: String, + onValueChange: (String) -> Unit, + singleLine: Boolean = true ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top + Column( + modifier = Modifier.fillMaxWidth() ) { Text( text = label, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(0.3f) + modifier = Modifier.padding(bottom = 4.dp) ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(0.7f) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + singleLine = singleLine, + textStyle = MaterialTheme.typography.bodyMedium ) } } @@ -287,7 +472,6 @@ class TermsDataViewActivity : BaseAppCompatActivity() { private const val TAXONOMY_SLUG = "taxonomy_slug" private const val IS_HIERARCHICAL = "is_hierarchical" private const val TAXONOMY_NAME = "taxonomy_name" - private const val KEY_TERM_ID = "termId" fun getIntent(context: Context, taxonomySlug: String, taxonomyName: String, isHierarchical: Boolean): Intent = Intent(context, TermsDataViewActivity::class.java).apply { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt index 05476bf5d764..7cc3ebda9bff 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/taxonomies/TermsViewModel.kt @@ -3,9 +3,15 @@ package org.wordpress.android.ui.taxonomies import android.content.Context import android.content.SharedPreferences import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.wordpress.android.R import org.wordpress.android.fluxc.model.SiteModel @@ -28,6 +34,8 @@ import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.TermEndpointType import uniffi.wp_api.TermListParams import uniffi.wp_api.AnyTermWithEditContext +import uniffi.wp_api.TermCreateParams +import uniffi.wp_api.TermUpdateParams import uniffi.wp_api.WpApiParamOrder import uniffi.wp_api.WpApiParamTermsOrderBy import javax.inject.Inject @@ -35,6 +43,31 @@ import javax.inject.Named private const val INDENTATION_IN_DP = 10 +enum class TermScreen { + List, + Detail, + Create +} + +data class TermDetailUiState( + val termId: Long = 0L, + val name: String = "", + val slug: String = "", + val description: String = "", + val count: Long = 0L, + val parentId: Long? = null, + val availableParents: List? = null, +) + +data class ParentOption( + val id: Long, + val name: String +) + +sealed class UiEvent { + data class ShowError(val messageRes: Int) : UiEvent() +} + @HiltViewModel class TermsViewModel @Inject constructor( @ApplicationContext private val context: Context, @@ -58,6 +91,23 @@ class TermsViewModel @Inject constructor( private var taxonomySlug: String = "" private var isHierarchical: Boolean = false private var currentTerms = listOf() + private var navController: NavHostController? = null + + private val _termDetailState = MutableStateFlow(null) + val termDetailState: StateFlow = _termDetailState.asStateFlow() + + private val _isSaving = MutableStateFlow(false) + val isSaving: StateFlow = _isSaving.asStateFlow() + + private val _isDeleting = MutableStateFlow(false) + val isDeleting: StateFlow = _isDeleting.asStateFlow() + + private val _uiEvent = MutableStateFlow(null) + val uiEvent = _uiEvent.asStateFlow() + + fun setNavController(navController: NavHostController) { + this.navController = navController + } fun initialize(taxonomySlug: String, isHierarchical: Boolean) { this.taxonomySlug = taxonomySlug @@ -65,6 +115,88 @@ class TermsViewModel @Inject constructor( initialize() } + fun navigateToTermDetail(termId: Long) { + val term = currentTerms.firstOrNull { it.id == termId } ?: return + + val availableParents = if (isHierarchical) { + val descendants = getDescendants(termId) + currentTerms + .filter { it.id != termId && it.id !in descendants } + .map { ParentOption(id = it.id, name = it.name) } + } else { + null + } + + _termDetailState.value = TermDetailUiState( + termId = term.id, + name = term.name, + slug = term.slug, + description = term.description, + count = term.count, + parentId = term.parent, + availableParents = availableParents, + ) + navController?.navigate(TermScreen.Detail.name) + } + + fun navigateToCreateTerm() { + val availableParents = if (isHierarchical) { + currentTerms.map { ParentOption(id = it.id, name = it.name) } + } else { + null + } + + _termDetailState.value = TermDetailUiState( + termId = 0L, // 0 indicates a new term + name = "", + slug = "", + description = "", + count = 0L, + parentId = 0L, + availableParents = availableParents, + ) + navController?.navigate(TermScreen.Create.name) + } + + fun navigateBack() { + clearTermDetail() + navController?.navigateUp() + } + + private fun getDescendants(termId: Long): Set { + val descendants = mutableSetOf() + + fun addDescendantsRecursively(parentId: Long) { + currentTerms.filter { it.parent == parentId }.forEach { child -> + descendants.add(child.id) + addDescendantsRecursively(child.id) + } + } + + addDescendantsRecursively(termId) + return descendants + } + + fun updateTermName(name: String) { + _termDetailState.value = _termDetailState.value?.copy(name = name) + } + + fun updateTermSlug(slug: String) { + _termDetailState.value = _termDetailState.value?.copy(slug = slug) + } + + fun updateTermDescription(description: String) { + _termDetailState.value = _termDetailState.value?.copy(description = description) + } + + fun updateTermParent(parentId: Long) { + _termDetailState.value = _termDetailState.value?.copy(parentId = parentId) + } + + fun clearTermDetail() { + _termDetailState.value = null + } + override fun getSupportedSorts(): List = if (isHierarchical) { listOf() } else { @@ -135,14 +267,107 @@ class TermsViewModel @Inject constructor( return result } - fun getTerm(termId: Long): AnyTermWithEditContext? { - val item = uiState.value.items.firstOrNull { - (it.data as? AnyTermWithEditContext)?.id == termId + fun saveTerm() { + viewModelScope.launch { + val selectedSite = selectedSiteRepository.getSelectedSite() + val currentTerm = _termDetailState.value + if (selectedSite == null || currentTerm == null) { + _uiEvent.value = UiEvent.ShowError(R.string.error_saving_term) + return@launch + } + + _isSaving.value = true + + val wpApiClient = wpApiClientProvider.getWpApiClient(selectedSite) + + val termsResponse = if (currentTerm.termId == 0L) { + // Create new term + wpApiClient.request { requestBuilder -> + requestBuilder.terms().create( + termEndpointType = getTermEndpointType(), + params = TermCreateParams( + name = currentTerm.name, + description = currentTerm.description, + slug = currentTerm.slug, + parent = if (isHierarchical) currentTerm.parentId else null + ) + ) + } + } else { + // Update existing term + wpApiClient.request { requestBuilder -> + requestBuilder.terms().update( + termEndpointType = getTermEndpointType(), + termId = currentTerm.termId, + params = TermUpdateParams( + name = currentTerm.name, + description = currentTerm.description, + slug = currentTerm.slug, + parent = currentTerm.parentId + ) + ) + } + } + + when (termsResponse) { + is WpRequestResult.Success -> { + _isSaving.value = false + // Clear term detail to navigate back + clearTermDetail() + // Reload the list + initialize() + } + + else -> { + _isSaving.value = false + _uiEvent.value = UiEvent.ShowError(R.string.error_saving_term) + appLogWrapper.e(AppLog.T.API, "Error saving term: $taxonomySlug") + } + } } - return item?.data as? AnyTermWithEditContext } - fun getAllTerms(): List = currentTerms + fun deleteTerm(termId: Long) { + viewModelScope.launch { + val selectedSite = selectedSiteRepository.getSelectedSite() + if (selectedSite == null) { + _uiEvent.value = UiEvent.ShowError(R.string.error_deleting_term) + return@launch + } + + _isDeleting.value = true + + val wpApiClient = wpApiClientProvider.getWpApiClient(selectedSite) + + val deleteResponse = wpApiClient.request { requestBuilder -> + requestBuilder.terms().delete( + termEndpointType = getTermEndpointType(), + termId = termId + ) + } + + when (deleteResponse) { + is WpRequestResult.Success -> { + _isDeleting.value = false + if (deleteResponse.response.data.deleted) { + // Clear term detail to navigate back + clearTermDetail() + // Reload the list + initialize() + } else { + _uiEvent.value = UiEvent.ShowError(R.string.error_deleting_term) + appLogWrapper.e(AppLog.T.API, "Term was not deleted: $taxonomySlug") + } + } + + else -> { + _isDeleting.value = false + _uiEvent.value = UiEvent.ShowError(R.string.error_deleting_term) + appLogWrapper.e(AppLog.T.API, "Error deleting term: $taxonomySlug") + } + } + } + } private fun convertToDataViewItem( allTerms: List, @@ -203,15 +428,9 @@ class TermsViewModel @Inject constructor( ): List { val wpApiClient = wpApiClientProvider.getWpApiClient(site) - val termEndpointType = when (taxonomySlug) { - DEFAULT_TAXONOMY_CATEGORY -> TermEndpointType.Categories - DEFAULT_TAXONOMY_TAG -> TermEndpointType.Tags - else -> TermEndpointType.Custom(taxonomySlug) - } - val termsResponse = wpApiClient.request { requestBuilder -> requestBuilder.terms().listWithEditContext( - termEndpointType = termEndpointType, + termEndpointType = getTermEndpointType(), params = TermListParams( page = page.toUInt(), search = searchQuery, @@ -247,6 +466,16 @@ class TermsViewModel @Inject constructor( } } + private fun getTermEndpointType(): TermEndpointType = when (taxonomySlug) { + DEFAULT_TAXONOMY_CATEGORY -> TermEndpointType.Categories + DEFAULT_TAXONOMY_TAG -> TermEndpointType.Tags + else -> TermEndpointType.Custom(taxonomySlug) + } + + fun consumeUIEvent() { + _uiEvent.value = null + } + companion object { private const val SORT_BY_NAME_ID = 1L private const val SORT_BY_COUNT_ID = 2L diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 18a22ddc45ee..d5de2f4981ba 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -5094,6 +5094,12 @@ translators: %s: Select control option value e.g: "Auto, 25%". --> Count: Count: %1$d Parent: + None Name Count + Delete term + Are you sure you want to delete \"%1$s\"? + Add term + There was an error saving the term + There was an error deleting the term diff --git a/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt index 3fcebaa07a85..6ba02adfdad5 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/taxonomies/TermsViewModelTest.kt @@ -2,14 +2,20 @@ package org.wordpress.android.ui.taxonomies import android.content.Context import android.content.SharedPreferences +import androidx.navigation.NavHostController import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.wordpress.android.BaseUnitTest import org.wordpress.android.R +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.network.rest.wpapi.rs.WpApiClientProvider import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.TaxonomyStore.DEFAULT_TAXONOMY_CATEGORY @@ -43,7 +49,7 @@ class TermsViewModelTest : BaseUnitTest() { @Before fun setUp() { - // Minimal setup - add more mocks in individual tests as needed + MockitoAnnotations.openMocks(this) } private fun createViewModel(): TermsViewModel { @@ -93,4 +99,171 @@ class TermsViewModelTest : BaseUnitTest() { assertThat(viewModel.uiState.value.loadingState) .isEqualTo(org.wordpress.android.ui.dataview.LoadingState.OFFLINE) } + + @Test + fun `setNavController stores navigation controller`() { + val viewModel = createViewModel() + val navController = mock() + + viewModel.setNavController(navController) + + // Should not throw - just verify it's stored + assertThat(viewModel).isNotNull + } + + @Test + fun `navigateToCreateTerm sets empty term detail state`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + + viewModel.navigateToCreateTerm() + + val state = viewModel.termDetailState.first() + assertThat(state).isNotNull + assertThat(state?.termId).isEqualTo(0L) + assertThat(state?.name).isEmpty() + assertThat(state?.slug).isEmpty() + assertThat(state?.description).isEmpty() + } + + @Test + fun `navigateToCreateTerm includes available parents for hierarchical taxonomy`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + + viewModel.navigateToCreateTerm() + + val state = viewModel.termDetailState.first() + // availableParents should be non-null for hierarchical taxonomies + assertThat(state?.availableParents).isNotNull + } + + @Test + fun `navigateToCreateTerm excludes available parents for non-hierarchical taxonomy`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_TAG, isHierarchical = false) + + viewModel.navigateToCreateTerm() + + val state = viewModel.termDetailState.first() + assertThat(state?.availableParents).isNull() + } + + @Test + fun `navigateBack clears term detail state`() = test { + val viewModel = createViewModel() + val navController = mock() + viewModel.setNavController(navController) + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.navigateBack() + + val state = viewModel.termDetailState.first() + assertThat(state).isNull() + verify(navController).navigateUp() + } + + @Test + fun `updateTermName updates term detail state`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.updateTermName("New Term Name") + + val state = viewModel.termDetailState.first() + assertThat(state?.name).isEqualTo("New Term Name") + } + + @Test + fun `updateTermSlug updates term detail state`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.updateTermSlug("new-slug") + + val state = viewModel.termDetailState.first() + assertThat(state?.slug).isEqualTo("new-slug") + } + + @Test + fun `updateTermDescription updates term detail state`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.updateTermDescription("New description") + + val state = viewModel.termDetailState.first() + assertThat(state?.description).isEqualTo("New description") + } + + @Test + fun `updateTermParent updates term detail state`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.updateTermParent(123L) + + val state = viewModel.termDetailState.first() + assertThat(state?.parentId).isEqualTo(123L) + } + + @Test + fun `clearTermDetail sets state to null`() = test { + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.clearTermDetail() + + val state = viewModel.termDetailState.first() + assertThat(state).isNull() + } + + @Test + fun `saveTerm sets error when site is null`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + viewModel.navigateToCreateTerm() + + viewModel.saveTerm() + advanceUntilIdle() + + val event = viewModel.uiEvent.first() + assertThat(event).isInstanceOf(UiEvent.ShowError::class.java) + assertThat((event as UiEvent.ShowError).messageRes).isEqualTo(R.string.error_saving_term) + } + + @Test + fun `saveTerm sets error when term detail is null`() = test { + val site = SiteModel() + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(site) + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + + viewModel.saveTerm() + advanceUntilIdle() + + val event = viewModel.uiEvent.first() + assertThat(event).isInstanceOf(UiEvent.ShowError::class.java) + } + + @Test + fun `deleteTerm sets error when site is null`() = test { + whenever(selectedSiteRepository.getSelectedSite()).thenReturn(null) + val viewModel = createViewModel() + viewModel.initialize(DEFAULT_TAXONOMY_CATEGORY, isHierarchical = true) + + viewModel.deleteTerm(123L) + advanceUntilIdle() + + val event = viewModel.uiEvent.first() + assertThat(event).isInstanceOf(UiEvent.ShowError::class.java) + assertThat((event as UiEvent.ShowError).messageRes).isEqualTo(R.string.error_deleting_term) + } }