Merge pull request #2 from JetBrains/samples/weather-app-remove-location

Add a context menu for location items and add remove location functio…
This commit is contained in:
Nebojsa Vuksic 2025-08-28 16:33:40 +02:00 committed by GitHub
commit b0ec980922
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 297 additions and 14 deletions

View File

@ -1,6 +1,7 @@
import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.Changelog
import org.jetbrains.changelog.markdownToHTML import org.jetbrains.changelog.markdownToHTML
import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
id("java") // Java support id("java") // Java support
@ -15,6 +16,14 @@ version = providers.gradleProperty("pluginVersion").get()
kotlin { kotlin {
jvmToolchain(21) jvmToolchain(21)
compilerOptions {
freeCompilerArgs.addAll(
listOf(
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
)
)
}
} }
repositories { repositories {
@ -35,6 +44,9 @@ dependencies {
testImplementation(libs.hamcrest) testImplementation(libs.hamcrest)
testImplementation(libs.composeuitest) testImplementation(libs.composeuitest)
testImplementation(libs.jewelstandalone) testImplementation(libs.jewelstandalone)
// Workaround for running tests on Windows and Linux
// It provides necessary Skiko runtime native binaries
testImplementation(libs.skikoAwtRuntimeAll)
intellijPlatform { intellijPlatform {
create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion")) create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion"))

View File

@ -4,8 +4,9 @@ junit = "4.13.2"
opentest4j = "1.3.0" opentest4j = "1.3.0"
hamcrest = "2.2" hamcrest = "2.2"
# Has to be in sync with IntelliJ Platform # Has to be in sync with IntelliJ Platform
composeuitest="1.8.0-alpha04" composeuitest = "1.8.0-alpha04"
jewelstandalone="0.29.0-251.27828" jewelstandalone = "0.29.0-251.27828"
skikoAwtRuntimeAll = "0.9.22"
# plugins # plugins
changelog = "2.2.1" changelog = "2.2.1"
@ -16,8 +17,10 @@ kotlin = "2.1.20"
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" }
hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" }
composeuitest = { group = "org.jetbrains.compose.ui", name ="ui-test-junit4-desktop", version.ref="composeuitest" } composeuitest = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4-desktop", version.ref = "composeuitest" }
jewelstandalone = { group = "org.jetbrains.jewel", name ="jewel-int-ui-standalone", version.ref="jewelstandalone" } jewelstandalone = { group = "org.jetbrains.jewel", name = "jewel-int-ui-standalone", version.ref = "jewelstandalone" }
skikoAwtRuntimeAll = { group = "org.jetbrains.skiko", name = "skiko-awt-runtime-all", version.ref = "skikoAwtRuntimeAll" }
[plugins] [plugins]
changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" }

View File

@ -0,0 +1,58 @@
package org.jetbrains.plugins.template.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.onClick
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupPositionProvider
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.Icon
import org.jetbrains.jewel.ui.component.PopupContainer
import org.jetbrains.jewel.ui.component.Text
import org.jetbrains.jewel.ui.icon.IconKey
@Composable
fun ContextPopupMenu(
popupPositionProvider: PopupPositionProvider,
onDismissRequest: () -> Unit,
content: @Composable () -> Unit,
) {
PopupContainer(
popupPositionProvider = popupPositionProvider,
modifier = Modifier.wrapContentSize(),
onDismissRequest = { onDismissRequest() },
horizontalAlignment = Alignment.Start
) {
content()
}
}
@Composable
fun ContextPopupMenuItem(
actionText: String,
actionIcon: IconKey,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.widthIn(min = 100.dp)
.padding(8.dp)
.onClick { onClick() },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
actionIcon,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = actionText,
style = JewelTheme.defaultTextStyle
)
}
}

View File

@ -1,14 +1,20 @@
package org.jetbrains.plugins.template.weatherApp.ui package org.jetbrains.plugins.template.weatherApp.ui
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.*
import androidx.compose.ui.window.PopupPositionProvider
import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn
import org.jetbrains.jewel.foundation.lazy.SelectionMode import org.jetbrains.jewel.foundation.lazy.SelectionMode
import org.jetbrains.jewel.foundation.lazy.itemsIndexed import org.jetbrains.jewel.foundation.lazy.itemsIndexed
@ -18,8 +24,10 @@ import org.jetbrains.jewel.ui.component.*
import org.jetbrains.jewel.ui.icon.IconKey import org.jetbrains.jewel.ui.icon.IconKey
import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.jewel.ui.icons.AllIconsKeys
import org.jetbrains.plugins.template.ComposeTemplateBundle import org.jetbrains.plugins.template.ComposeTemplateBundle
import org.jetbrains.plugins.template.components.ContextPopupMenu
import org.jetbrains.plugins.template.components.ContextPopupMenuItem
import org.jetbrains.plugins.template.weatherApp.model.Location import org.jetbrains.plugins.template.weatherApp.model.Location
import org.jetbrains.plugins.template.weatherApp.services.* import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider
import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu
import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard
@ -119,6 +127,7 @@ private fun EmptyListPlaceholder(
} }
} }
@OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
private fun MyLocationList( private fun MyLocationList(
myLocationsUIState: LocationsUIState, myLocationsUIState: LocationsUIState,
@ -158,9 +167,66 @@ private fun MyLocationList(
itemsIndexed( itemsIndexed(
items = myLocationsUIState.locations, items = myLocationsUIState.locations,
key = { _, item -> item.label }, key = { _, item -> item.label },
) { index, item -> ) { index, locationItem ->
SimpleListItem(text = item.label, isSelected = myLocationsUIState.selectedIndex == index, isActive = isActive) Box(Modifier.wrapContentSize()) {
val showPopup = remember { mutableStateOf(false) }
val popupPosition = remember { mutableStateOf(IntOffset.Zero) }
val itemPosition = remember { mutableStateOf(Offset.Zero) }
SimpleListItem(
text = locationItem.label,
isSelected = myLocationsUIState.selectedIndex == index,
isActive = isActive,
modifier = Modifier
.onGloballyPositioned { coordinates ->
itemPosition.value = coordinates.positionInWindow()
}
.onPointerEvent(PointerEventType.Press) { pointerEvent ->
if (!pointerEvent.buttons.isSecondaryPressed) return@onPointerEvent
// Calculate exact click position
val clickOffset = pointerEvent.changes.first().position
popupPosition.value = IntOffset(
x = (itemPosition.value.x + clickOffset.x).toInt(),
y = (itemPosition.value.y + clickOffset.y).toInt()
)
showPopup.value = true
}
)
if (showPopup.value) {
val popupPositionProvider = remember(popupPosition.value) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset = popupPosition.value
}
}
ContextPopupMenu(
popupPositionProvider,
onDismissRequest = {
showPopup.value = false
popupPosition.value = IntOffset.Zero
itemPosition.value = Offset.Zero
}
) {
ContextPopupMenuItem(
ComposeTemplateBundle.getMessage("weather.app.context.menu.delete.option"),
AllIconsKeys.General.Delete
) {
showPopup.value = false
myLocationsViewModelApi.onDeleteLocation(locationItem)
}
}
}
}
} }
} }
} }

View File

@ -1,7 +1,6 @@
package org.jetbrains.plugins.template.weatherApp.ui.components package org.jetbrains.plugins.template.weatherApp.ui.components
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@ -47,7 +46,6 @@ import java.util.*
* @param onReloadWeatherData Callback invoked to reload weather data when the refresh action is triggered. It * @param onReloadWeatherData Callback invoked to reload weather data when the refresh action is triggered. It
* provides the location for which the weather data should be fetched. * provides the location for which the weather data should be fetched.
*/ */
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun WeatherDetailsCard( fun WeatherDetailsCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

@ -9,4 +9,6 @@ weather.app.search.toolbar.menu.add.button.text=Add
weather.app.search.toolbar.menu.add.button.content.description=Add a place to a watch list. weather.app.search.toolbar.menu.add.button.content.description=Add a place to a watch list.
weather.app.7days.forecast.title.text=7-day Forecast weather.app.7days.forecast.title.text=7-day Forecast
weather.app.context.menu.delete.option=Delete
weather.app.clear.button.content.description=Clear weather.app.clear.button.content.description=Clear

View File

@ -0,0 +1,144 @@
package org.jetbrains.plugins.template.weatherApp.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.ComposeTestRule
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.jetbrains.plugins.template.ComposeBasedTestCase
import org.jetbrains.plugins.template.weatherApp.model.Location
import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider
import org.junit.Test
internal class WeatherAppSampleUiTest : ComposeBasedTestCase() {
private val fakeSearchProvider = FakeSearchProvider()
private val fakeMyLocationsViewModel = FakeMyLocationsViewModel()
private val fakeWeatherViewModel = FakeWeatherViewModel()
override val contentUnderTest: @Composable () -> Unit = {
WeatherAppSample(
myLocationViewModel = fakeMyLocationsViewModel,
weatherViewModelApi = fakeWeatherViewModel,
searchAutoCompletionItemProvider = fakeSearchProvider
)
}
@Test
fun `add location via search UI then remove it`() = runComposeTest {
val robot = WeatherSampleRobot(this)
// Add a location via UI: type, select autocomplete, click Add
robot.focusAndTypeInSearchField("Mun")
robot.waitForAutocomplete("Munich, Germany")
robot.clickOnAutocompleteItem("Munich, Germany")
robot.clickAddButton()
// Verify the item appears selected in My Locations
robot.verifyListItemWithTextIsSelected("Munich, Germany")
// Remove the item via UI: open context menu with primary click (test mode) and click Delete
robot.rightClickOnListItem("Munich, Germany")
robot.clickDeleteInContextMenu()
// Verify empty placeholder is shown again
robot.verifyNoLocationsPlaceHolderVisible()
}
private class WeatherSampleRobot(private val rule: ComposeTestRule) {
fun idle() = rule.waitForIdle()
fun focusAndTypeInSearchField(text: String) {
val field = rule.onNode(hasSetTextAction())
field.performClick()
field.performTextInput(text)
}
fun waitForAutocomplete(itemLabel: String) {
rule.waitUntil(timeoutMillis = 100) {
rule.onAllNodesWithText(itemLabel).fetchSemanticsNodes().isNotEmpty()
}
}
fun clickOnAutocompleteItem(itemLabel: String) {
rule.onNodeWithText(itemLabel).performClick()
}
fun clickAddButton() {
rule.onNodeWithText("Add").performClick()
}
fun rightClickOnListItem(text: String) {
rule.onNodeWithText(text)
.assertExists("No node found with text: $text")
.performMouseInput { rightClick() }
}
fun clickDeleteInContextMenu() {
rule.onNodeWithText("Delete").performClick()
}
fun verifyListItemWithTextIsSelected(text: String) {
rule.onNodeWithText(text).assertIsSelected()
}
fun verifyNoLocationsPlaceHolderVisible() {
rule.onNodeWithText("No locations added yet. Go and add the first location.").assertExists()
}
}
private class FakeSearchProvider : SearchAutoCompletionItemProvider<Location> {
override fun provideSearchableItems(searchTerm: String): List<Location> {
if (searchTerm.isBlank()) return emptyList()
// Provide a small fixed set that includes Munich and others regardless of query for simplicity
return listOf(
Location("Munich", "Germany"),
Location("Berlin", "Germany"),
Location("Paris", "France"),
).filter { it.label.contains(searchTerm, ignoreCase = true) }
}
}
private class FakeMyLocationsViewModel : MyLocationsViewModelApi {
private val _state = MutableStateFlow(LocationsUIState.empty())
override val myLocationsUIStateFlow: Flow<LocationsUIState>
get() = _state.asStateFlow()
override fun onAddLocation(locationToAdd: Location) {
_state.value = _state.value.withLocationAdded(locationToAdd)
}
override fun onDeleteLocation(locationToDelete: Location) {
_state.value = _state.value.withLocationDeleted(locationToDelete)
}
override fun onLocationSelected(selectedLocationIndex: Int) {
_state.value = _state.value.withItemAtIndexSelected(selectedLocationIndex)
}
override fun dispose() {
// no-op for tests
}
}
private class FakeWeatherViewModel : WeatherViewModelApi {
private val _weatherState = MutableStateFlow<WeatherForecastUIState>(WeatherForecastUIState.Empty)
override val weatherForecastUIState: Flow<WeatherForecastUIState>
get() = _weatherState.asStateFlow()
override fun onLoadWeatherForecast(location: Location) {
// no-op
}
override fun onReloadWeatherForecast() {
// no-op
}
override fun dispose() {
// no-op
}
}
}