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.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"))
|
||||||
|
|||||||
@ -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" }
|
||||||
|
|||||||
@ -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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
@ -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