Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
99c142b
Add pagination to navigation menus lists
nbradbury Feb 5, 2026
6942c64
Fix pagination and improve menu list UI
nbradbury Feb 5, 2026
c609253
Replace linkable items dropdown with modal bottom sheet
nbradbury Feb 5, 2026
f49765a
Remove unused menu_item_count plural resource
nbradbury Feb 5, 2026
2c62c3e
Simplify navigation menus code
nbradbury Feb 5, 2026
1aa9312
Add mutex protection to pagination functions
nbradbury Feb 5, 2026
aab08fb
Use whitelist for url validation
nbradbury Feb 5, 2026
7d6c964
Fix pagination state reset on error
nbradbury Feb 5, 2026
e8be827
Cancel linkable items loading job when menu item type changes
nbradbury Feb 5, 2026
973124b
Suppress LongMethod detekt warning
nbradbury Feb 5, 2026
4d8b96f
Update URL validation to use WordPress allowed protocols
nbradbury Feb 5, 2026
e3295e1
Fix pagination offset derivation and error state handling
nbradbury Feb 5, 2026
a166ebd
Optimize sortItemsHierarchically with pre-computed children map
nbradbury Feb 5, 2026
89dff5f
Refactor isValidLinkUrl to fix detekt ReturnCount violation
nbradbury Feb 5, 2026
e56a631
Add Compose previews for all navigation menu screens
nbradbury Feb 5, 2026
553f3b2
Fixed minor warnings
nbradbury Feb 5, 2026
1490a02
Wrap Compose previews with AppThemeM3 for proper theming
nbradbury Feb 5, 2026
b0385bb
Merge branch 'trunk' into issue/menus-compose-previews
nbradbury Feb 6, 2026
2e820d6
Resolved conflicts
nbradbury Feb 6, 2026
932c7a3
Merge branch 'trunk' of https://github.com/wordpress-mobile/WordPress…
nbradbury Feb 6, 2026
a2d06c8
Fixed warnings
nbradbury Feb 6, 2026
34c10b3
Reverted changes to Dangerfile
nbradbury Feb 6, 2026
c6ca0a5
Add Compose preview for LinkableItemBottomSheetContent
nbradbury Feb 7, 2026
8352246
Merge branch 'trunk' into issue/menus-compose-previews
nbradbury Feb 7, 2026
3531a9e
Merge branch 'trunk' into issue/menus-compose-previews
nbradbury Feb 9, 2026
f04cc09
Merge branch 'trunk' into issue/menus-compose-previews
nbradbury Feb 9, 2026
6e94749
Add missing onFabVisibilityChange parameter to preview functions
nbradbury Feb 9, 2026
8c66d79
Fix bottom sheet dismiss behavior in linkable item selector
nbradbury Feb 9, 2026
7be343c
Fix navigation menu bugs: cache sync, error handling, and null safety
nbradbury Feb 9, 2026
6fe1eee
Fix navigation menu bugs: cache sync, error handling, and null safety
nbradbury Feb 9, 2026
b187b0e
Merge branch 'trunk' into issue/menu-fixes
nbradbury Feb 10, 2026
a964c43
Pass menu item type when creating and updating menu items
nbradbury Feb 10, 2026
60f54be
Log unknown menu types
nbradbury Feb 10, 2026
c6b413a
Added Kdoc
nbradbury Feb 10, 2026
6bffe0a
Merge branch 'issue/menu-types' into issue/menu-fixes
nbradbury Feb 10, 2026
513a658
Use itemType enum instead of typeLabel for menu item type mapping
nbradbury Feb 10, 2026
895d0dc
Merge branch 'issue/menu-types' into issue/menu-fixes
nbradbury Feb 10, 2026
a49f46d
Move input trimming from keystroke handlers to save time
nbradbury Feb 10, 2026
c5ef36b
Fix keyboard overlapping Save button and improve auto-add switch layout
nbradbury Feb 11, 2026
c2d0970
Replace URL validation with normalization for menu item links
nbradbury Feb 11, 2026
eb73b72
Suppress ReturnCount warning on normalizeUrl
nbradbury Feb 11, 2026
c148937
Merge branch 'trunk' into issue/menu-fixes
nbradbury Feb 11, 2026
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
Expand Up @@ -4,6 +4,7 @@ import androidx.annotation.StringRes
import org.json.JSONArray
import org.json.JSONException
import org.wordpress.android.R
import org.wordpress.android.util.AppLog
import org.wordpress.android.ui.navmenus.models.NavMenuItemModel
import org.wordpress.android.ui.navmenus.models.NavMenuLocationModel
import org.wordpress.android.ui.navmenus.models.NavMenuModel
Expand Down Expand Up @@ -203,7 +204,8 @@ fun String.parseJsonStringArray(): List<String> {
return try {
val jsonArray = JSONArray(this)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (_: JSONException) {
} catch (e: JSONException) {
AppLog.w(AppLog.T.API, "Failed to parse JSON string array: $e")
emptyList()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,18 @@ class NavMenusViewModel @Inject constructor(
availableLocations = _menuListState.value.locations,
isNew = true
)
navController?.navigate(NavMenuScreen.MenuDetail.name)
navigateTo(NavMenuScreen.MenuDetail.name)
}

fun navigateToEditMenu(menuId: Long) {
val menu = currentMenus.find { it.remoteMenuId == menuId } ?: return
val menu = currentMenus.find { it.remoteMenuId == menuId }
if (menu == null) {
AppLog.w(AppLog.T.API, "Menu not found in cache: $menuId")
_uiEvent.value = NavMenusUiEvent.ShowError(
resourceProvider.getString(R.string.error_generic)
)
return
}

_menuDetailState.value = MenuDetailUiState(
menuId = menu.remoteMenuId,
Expand All @@ -235,17 +242,24 @@ class NavMenusViewModel @Inject constructor(
availableLocations = _menuListState.value.locations,
isNew = false
)
navController?.navigate(NavMenuScreen.MenuDetail.name)
navigateTo(NavMenuScreen.MenuDetail.name)
}

fun navigateToMenuItems(menuId: Long) {
val menu = currentMenus.find { it.remoteMenuId == menuId } ?: return
val menu = currentMenus.find { it.remoteMenuId == menuId }
if (menu == null) {
AppLog.w(AppLog.T.API, "Menu not found in cache: $menuId")
_uiEvent.value = NavMenusUiEvent.ShowError(
resourceProvider.getString(R.string.error_generic)
)
return
}
_menuItemListState.value = MenuItemListUiState(
isLoading = true,
menuId = menuId,
menuName = menu.name
)
navController?.navigate(NavMenuScreen.MenuItemList.name)
navigateTo(NavMenuScreen.MenuItemList.name)
loadMenuItems(menuId)
}

Expand Down Expand Up @@ -377,11 +391,18 @@ class NavMenusViewModel @Inject constructor(
menuOrder = currentMenuItems.maxOfOrNull { it.menuOrder }?.plus(1) ?: 1,
isNew = true
)
navController?.navigate(NavMenuScreen.MenuItemDetail.name)
navigateTo(NavMenuScreen.MenuItemDetail.name)
}

fun navigateToEditMenuItem(itemId: Long) {
val item = currentMenuItems.find { it.remoteItemId == itemId } ?: return
val item = currentMenuItems.find { it.remoteItemId == itemId }
if (item == null) {
AppLog.w(AppLog.T.API, "Menu item not found in cache: $itemId")
_uiEvent.value = NavMenusUiEvent.ShowError(
resourceProvider.getString(R.string.error_generic)
)
return
}
val availableParents = buildAvailableParents(itemId)

_menuItemDetailState.value = MenuItemDetailUiState(
Expand All @@ -401,7 +422,7 @@ class NavMenusViewModel @Inject constructor(
availableParents = availableParents,
isNew = false
)
navController?.navigate(NavMenuScreen.MenuItemDetail.name)
navigateTo(NavMenuScreen.MenuItemDetail.name)
}

private fun buildAvailableParents(excludeItemId: Long): List<ParentItemOption> {
Expand Down Expand Up @@ -437,9 +458,21 @@ class NavMenusViewModel @Inject constructor(
}

fun navigateBack() {
if (navController == null) {
AppLog.w(AppLog.T.MAIN, "NavController not set, cannot navigate back")
return
}
navController?.navigateUp()
}

private fun navigateTo(route: String) {
if (navController == null) {
AppLog.w(AppLog.T.MAIN, "NavController not set, cannot navigate to $route")
return
}
navController?.navigate(route)
}

// Menu detail update methods
fun updateMenuName(name: String) {
val sanitized = sanitizeInput(name, MAX_MENU_NAME_LENGTH)
Expand Down Expand Up @@ -471,7 +504,7 @@ class NavMenusViewModel @Inject constructor(
val state = _menuDetailState.value ?: return@launch
val site = selectedSiteRepository.getSelectedSite() ?: return@launch

if (state.name.isBlank()) {
if (state.name.trim().isBlank()) {
_uiEvent.value = NavMenusUiEvent.ShowError(
resourceProvider.getString(R.string.menu_name_required)
)
Expand All @@ -483,8 +516,8 @@ class NavMenusViewModel @Inject constructor(
val menu = NavMenuModel(
localSiteId = site.id,
remoteMenuId = state.menuId,
name = state.name,
description = state.description,
name = state.name.trim(),
description = state.description.trim(),
locations = state.selectedLocations.toJsonStringArray(),
autoAdd = state.autoAdd
)
Expand Down Expand Up @@ -549,7 +582,7 @@ class NavMenusViewModel @Inject constructor(
}

fun updateMenuItemUrl(url: String) {
_menuItemDetailState.value = _menuItemDetailState.value?.copy(url = url.trim())
_menuItemDetailState.value = _menuItemDetailState.value?.copy(url = url)
}

fun updateMenuItemParent(parentId: Long) {
Expand Down Expand Up @@ -809,10 +842,13 @@ class NavMenusViewModel @Inject constructor(

private enum class ReorderDirection { UP, DOWN }

private suspend fun updateMenuItemOrder(site: SiteModel, itemId: Long, targetItemId: Long): Boolean {
private suspend fun updateMenuItemOrder(
site: SiteModel,
itemId: Long,
targetItemId: Long
): Boolean {
val itemToMove = currentMenuItems.find { it.remoteItemId == itemId }
val swapWithItem = currentMenuItems.find { it.remoteItemId == targetItemId }

if (itemToMove == null || swapWithItem == null) return false

val updatedItemToMove = itemToMove.copy(menuOrder = swapWithItem.menuOrder)
Expand All @@ -825,7 +861,17 @@ class NavMenusViewModel @Inject constructor(
result1
}

return result2 is NavMenuRestClient.NavMenuItemResult.Success
val success = result2 is NavMenuRestClient.NavMenuItemResult.Success
if (success) {
currentMenuItems = currentMenuItems.map { item ->
when (item.remoteItemId) {
itemId -> updatedItemToMove
targetItemId -> updatedSwapWithItem
else -> item
}
}
}
return success
}

fun saveMenuItem() {
Expand Down Expand Up @@ -874,8 +920,6 @@ class NavMenusViewModel @Inject constructor(
resourceProvider.getString(R.string.menu_item_url_required)
state.selectedTypeOption != MenuItemTypeOption.CUSTOM_LINK && state.objectId <= 0 ->
resourceProvider.getString(R.string.menu_item_select_required)
state.url.isNotBlank() && !isValidLinkUrl(state.url) ->
resourceProvider.getString(R.string.menu_item_invalid_url)
else -> null
}
}
Expand All @@ -885,8 +929,8 @@ class NavMenusViewModel @Inject constructor(
localSiteId = site.id,
remoteItemId = state.itemId,
menuId = state.menuId,
title = state.title,
url = state.url,
title = state.title.trim(),
url = normalizeUrl(state.url),
type = state.type,
objectType = state.objectType,
objectId = state.objectId,
Expand All @@ -898,7 +942,7 @@ class NavMenusViewModel @Inject constructor(
} else {
"[]"
},
description = state.description,
description = state.description.trim(),
attrTitle = state.attrTitle
)
}
Expand Down Expand Up @@ -937,24 +981,28 @@ class NavMenusViewModel @Inject constructor(
}

/**
* Validates a URL against WordPress's allowed protocols.
* See: https://developer.wordpress.org/reference/functions/wp_allowed_protocols/
* Normalizes a URL by adding https:// if no recognized protocol is present.
* Matches WordPress web behavior where URLs are sanitized rather than rejected.
*/
private fun isValidLinkUrl(url: String): Boolean {
val trimmedUrl = url.trim()
if (trimmedUrl.isEmpty()) return false

return when {
// Anchor links are valid (e.g., #section or #contact)
trimmedUrl.startsWith("#") -> trimmedUrl.length > 1
// Protocol-relative URLs are valid
trimmedUrl.startsWith("//") -> trimmedUrl.length > 2
else -> ALLOWED_PROTOCOLS.any { trimmedUrl.lowercase().startsWith("$it:") }
@Suppress("ReturnCount")
private fun normalizeUrl(url: String): String {
val trimmed = url.trim()
if (trimmed.isEmpty() || trimmed.startsWith("#")) return trimmed
if (ALLOWED_PROTOCOLS.any { trimmed.lowercase().startsWith("$it:") }) {
return trimmed
}
if (trimmed.startsWith("//")) return "https:$trimmed"
// Relative paths (e.g. /about, ./page, ../page) - keep as-is
if (trimmed.startsWith("/") || trimmed.startsWith("./") ||
trimmed.startsWith("../")
) {
return trimmed
}
return "https://$trimmed"
}

private fun sanitizeInput(input: String, maxLength: Int): String {
return input.trim().take(maxLength)
return input.take(maxLength)
}

companion object {
Expand All @@ -964,8 +1012,8 @@ class NavMenusViewModel @Inject constructor(
private const val MAX_MENU_ITEM_DESCRIPTION_LENGTH = 500

/**
* WordPress allowed protocols from wp_allowed_protocols().
* See: https://developer.wordpress.org/reference/functions/wp_allowed_protocols/
* Recognized protocols that should not have https:// prepended.
* Based on wp_allowed_protocols().
*/
private val ALLOWED_PROTOCOLS = listOf(
"http", "https", "ftp", "ftps", "mailto", "news", "irc", "irc6", "ircs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
Expand Down Expand Up @@ -50,6 +51,7 @@ fun MenuDetailScreen(
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
Expand Down Expand Up @@ -118,11 +120,15 @@ private fun MenuBasicInfoCard(
)

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(stringResource(R.string.menu_auto_add_pages))
Text(
text = stringResource(R.string.menu_auto_add_pages),
modifier = Modifier.weight(1f)
)
Switch(
checked = autoAdd,
onCheckedChange = onAutoAddChange
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
Expand Down Expand Up @@ -77,6 +78,7 @@ fun MenuItemDetailScreen(
Column(
modifier = modifier
.fillMaxSize()
.imePadding()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
Expand Down
Loading