mirror of
https://github.com/JetBrains/intellij-platform-plugin-template.git
synced 2025-12-05 06:11:52 +00:00
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:
commit
b0ec980922
@ -1,6 +1,7 @@
|
||||
import org.jetbrains.changelog.Changelog
|
||||
import org.jetbrains.changelog.markdownToHTML
|
||||
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
id("java") // Java support
|
||||
@ -15,6 +16,14 @@ version = providers.gradleProperty("pluginVersion").get()
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
listOf(
|
||||
"-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
@ -35,6 +44,9 @@ dependencies {
|
||||
testImplementation(libs.hamcrest)
|
||||
testImplementation(libs.composeuitest)
|
||||
testImplementation(libs.jewelstandalone)
|
||||
// Workaround for running tests on Windows and Linux
|
||||
// It provides necessary Skiko runtime native binaries
|
||||
testImplementation(libs.skikoAwtRuntimeAll)
|
||||
|
||||
intellijPlatform {
|
||||
create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion"))
|
||||
|
||||
@ -6,6 +6,7 @@ hamcrest = "2.2"
|
||||
# Has to be in sync with IntelliJ Platform
|
||||
composeuitest = "1.8.0-alpha04"
|
||||
jewelstandalone = "0.29.0-251.27828"
|
||||
skikoAwtRuntimeAll = "0.9.22"
|
||||
|
||||
# plugins
|
||||
changelog = "2.2.1"
|
||||
@ -18,6 +19,8 @@ opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "ope
|
||||
hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" }
|
||||
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" }
|
||||
skikoAwtRuntimeAll = { group = "org.jetbrains.skiko", name = "skiko-awt-runtime-all", version.ref = "skikoAwtRuntimeAll" }
|
||||
|
||||
|
||||
[plugins]
|
||||
changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" }
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,20 @@
|
||||
package org.jetbrains.plugins.template.weatherApp.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
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.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.SelectionMode
|
||||
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.icons.AllIconsKeys
|
||||
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.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.WeatherDetailsCard
|
||||
|
||||
@ -119,6 +127,7 @@ private fun EmptyListPlaceholder(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun MyLocationList(
|
||||
myLocationsUIState: LocationsUIState,
|
||||
@ -158,9 +167,66 @@ private fun MyLocationList(
|
||||
itemsIndexed(
|
||||
items = myLocationsUIState.locations,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package org.jetbrains.plugins.template.weatherApp.ui.components
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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
|
||||
* provides the location for which the weather data should be fetched.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun WeatherDetailsCard(
|
||||
modifier: Modifier = Modifier,
|
||||
|
||||
@ -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.7days.forecast.title.text=7-day Forecast
|
||||
|
||||
weather.app.context.menu.delete.option=Delete
|
||||
|
||||
weather.app.clear.button.content.description=Clear
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user