[collab] implement review list quick filters

GitOrigin-RevId: 294f0921ff5f8ec5bc7b41e23a0317bfbfceb882
This commit is contained in:
Ivan Semenov
2022-07-01 13:08:11 +00:00
committed by intellij-monorepo-bot
parent 9e8ee409fe
commit 2386c62d18
10 changed files with 143 additions and 27 deletions
@@ -22,4 +22,6 @@ login.token.generate=Generate\u2026
login.progress=Logging in\u2026
login.dialog.title=Log In with Access Token
review.list.info={0} \u00B7 created {1}
review.list.info.author={0} \u00B7 created {1}, by {2}
review.list.info.author={0} \u00B7 created {1}, by {2}
review.list.filter.quick.title=Quick Filters
review.list.filter.quick.clear=Clear {0} Filters
@@ -1,21 +1,31 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.collaboration.ui.codereview.list.search
import com.intellij.collaboration.messages.CollaborationToolsBundle
import com.intellij.collaboration.ui.codereview.InlineIconButton
import com.intellij.collaboration.ui.codereview.list.search.ChooserPopupUtil.showAndAwaitListSubmission
import com.intellij.icons.AllIcons
import com.intellij.ide.DataManager
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.actionSystem.Separator
import com.intellij.openapi.actionSystem.Toggleable
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.ui.IdeBorderFactory
import com.intellij.ui.ScrollPaneFactory
import com.intellij.ui.SideBorder
import com.intellij.ui.SimpleListCellRenderer
import com.intellij.ui.*
import com.intellij.ui.components.GradientViewport
import com.intellij.ui.components.JBThinOverlappingScrollBar
import com.intellij.ui.components.panels.HorizontalLayout
import com.intellij.ui.scale.JBUIScale
import com.intellij.util.ui.JBUI
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.annotations.Nls
import java.awt.Adjustable
import java.awt.BorderLayout
import java.awt.*
import java.awt.event.ActionListener
import java.awt.geom.Ellipse2D
import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
@@ -24,7 +34,7 @@ abstract class ReviewListSearchPanelFactory<S : ReviewListSearchValue, VM : Revi
protected val vm: VM
) {
fun create(viewScope: CoroutineScope): JComponent {
fun create(viewScope: CoroutineScope, quickFilters: List<Pair<@Nls String, S>>): JComponent {
val searchField = ReviewListSearchTextFieldFactory(vm.queryState).create(viewScope, chooseFromHistory = { point ->
val value = JBPopupFactory.getInstance()
.createPopupChooserBuilder(vm.getSearchHistory().reversed())
@@ -41,11 +51,11 @@ abstract class ReviewListSearchPanelFactory<S : ReviewListSearchValue, VM : Revi
val filters = createFilters(viewScope)
val filtersPanel = JPanel(HorizontalLayout(4)).apply {
border = JBUI.Borders.emptyTop(10)
isOpaque = false
filters.forEach { add(it, HorizontalLayout.LEFT) }
}.let {
ScrollPaneFactory.createScrollPane(it, true).apply {
viewport = GradientViewport(it, JBUI.insetsRight(10), false)
viewport = GradientViewport(it, JBUI.insets(0, 10), false)
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS
@@ -53,10 +63,19 @@ abstract class ReviewListSearchPanelFactory<S : ReviewListSearchValue, VM : Revi
}
}
val quickFilterButton = QuickFilterButtonFactory().create(viewScope, quickFilters)
val filterPanel = JPanel(BorderLayout()).apply {
border = JBUI.Borders.emptyTop(10)
isOpaque = false
add(quickFilterButton, BorderLayout.WEST)
add(filtersPanel, BorderLayout.CENTER)
}
val searchPanel = JPanel(BorderLayout()).apply {
border = JBUI.Borders.compound(IdeBorderFactory.createBorder(SideBorder.BOTTOM), JBUI.Borders.empty(8, 10, 0, 10))
add(searchField, BorderLayout.CENTER)
add(filtersPanel, BorderLayout.SOUTH)
add(filterPanel, BorderLayout.SOUTH)
}
return searchPanel
@@ -65,4 +84,67 @@ abstract class ReviewListSearchPanelFactory<S : ReviewListSearchValue, VM : Revi
protected abstract fun getShortText(searchValue: S): @Nls String
protected abstract fun createFilters(viewScope: CoroutineScope): List<JComponent>
private inner class QuickFilterButtonFactory {
fun create(viewScope: CoroutineScope, quickFilters: List<Pair<String, S>>): JComponent {
val button = InlineIconButton(AllIcons.General.Filter).apply {
border = JBUI.Borders.empty(3)
}.also {
it.actionListener = ActionListener { _ ->
showQuickFiltersPopup(it, quickFilters)
}
}
viewScope.launch {
vm.searchState.collect {
button.icon = if (it.filterCount == 0) AllIcons.General.Filter else IconWithNotifyDot(AllIcons.General.Filter)
}
}
return button
}
private fun showQuickFiltersPopup(parentComponent: JComponent, quickFilters: List<Pair<@Nls String, S>>) {
val quickFiltersActions =
quickFilters.map { (name, search) -> QuickFilterAction(name, search) } +
Separator() +
ClearFiltersAction()
JBPopupFactory.getInstance()
.createActionGroupPopup(CollaborationToolsBundle.message("review.list.filter.quick.title"), DefaultActionGroup(quickFiltersActions),
DataManager.getInstance().getDataContext(parentComponent),
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
false)
.showUnderneathOf(parentComponent)
}
private inner class QuickFilterAction(name: @Nls String, private val search: S)
: DumbAwareAction(name), Toggleable {
override fun update(e: AnActionEvent) = Toggleable.setSelected(e.presentation, vm.searchState.value == search)
override fun actionPerformed(e: AnActionEvent) = vm.searchState.update { search }
}
private inner class ClearFiltersAction
: DumbAwareAction(CollaborationToolsBundle.message("review.list.filter.quick.clear", vm.searchState.value.filterCount)) {
override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = vm.searchState.value.filterCount > 0
}
override fun actionPerformed(e: AnActionEvent) = vm.searchState.update { vm.emptySearch }
}
//TODO: request a ready-made icon from UI and also a proper icon for old UI
private inner class IconWithNotifyDot(private val originalIcon: Icon) : Icon by originalIcon {
override fun paintIcon(c: Component?, g: Graphics?, x: Int, y: Int) {
originalIcon.paintIcon(c, g, x, y)
g as Graphics2D
val dotSize = JBUIScale.scale(6)
val notifyDotShape = Ellipse2D.Float((iconWidth - dotSize).toFloat(), 0f, dotSize.toFloat(), dotSize.toFloat())
g.color = ColorUtil.fromHex("#3574F0")
g.fill(notifyDotShape)
}
}
}
}
@@ -7,5 +7,8 @@ interface ReviewListSearchPanelViewModel<S : ReviewListSearchValue> {
val searchState: MutableStateFlow<S>
val queryState: MutableStateFlow<String?>
val emptySearch: S
val defaultSearch: S
fun getSearchHistory(): List<S>
}
@@ -9,7 +9,8 @@ import kotlinx.coroutines.launch
abstract class ReviewListSearchPanelViewModelBase<S : ReviewListSearchValue>(
private val scope: CoroutineScope,
private val historyModel: ReviewListSearchHistoryModel<S>,
private val defaultSearch: S
final override val emptySearch: S,
final override val defaultSearch: S
) : ReviewListSearchPanelViewModel<S> {
final override val searchState = MutableStateFlow(historyModel.getHistory().lastOrNull() ?: defaultSearch)
@@ -18,7 +19,7 @@ abstract class ReviewListSearchPanelViewModelBase<S : ReviewListSearchValue>(
withQuery(it)
}
override fun getSearchHistory(): List<S> = historyModel.getHistory()
final override fun getSearchHistory(): List<S> = historyModel.getHistory()
init {
updateHistoryOnSearchChanges()
@@ -32,7 +33,7 @@ abstract class ReviewListSearchPanelViewModelBase<S : ReviewListSearchValue>(
return@collectLatestWithPrevious
}
if (new.isEmpty || new == defaultSearch) {
if (new.filterCount == 0 || new == defaultSearch) {
return@collectLatestWithPrevious
}
@@ -3,6 +3,6 @@ package com.intellij.collaboration.ui.codereview.list.search
interface ReviewListSearchValue {
val searchQuery: String?
val isEmpty: Boolean
get() = searchQuery == null
val filterCount: Int
get() = if(searchQuery != null) 1 else 0
}
@@ -219,6 +219,9 @@ pull.request.list.filter.review.awaiting.short=Awaiting review
pull.request.list.filter.review.awaiting.full=Awaiting review from you
pull.request.list.filter.author=Author
pull.request.list.filter.label=Label
pull.request.list.filter.quick.open=Open
pull.request.list.filter.quick.yours=Your pull requests
pull.request.list.filter.quick.assigned=Assigned to you
pull.request.open.action=View Pull Request
pull.request.select.action=Select Opened Pull Request
pull.request.back.to.list=Back to List
@@ -22,7 +22,6 @@ import com.intellij.vcs.log.ui.frame.ProgressStripe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -34,6 +33,7 @@ import org.jetbrains.plugins.github.pullrequest.data.GHListLoader
import org.jetbrains.plugins.github.pullrequest.data.GHPRListLoader
import org.jetbrains.plugins.github.pullrequest.data.GHPRListUpdatesChecker
import org.jetbrains.plugins.github.pullrequest.data.service.GHPRRepositoryDataService
import org.jetbrains.plugins.github.pullrequest.data.service.GHPRSecurityService
import org.jetbrains.plugins.github.pullrequest.ui.GHApiLoadingErrorHandler
import org.jetbrains.plugins.github.ui.avatars.GHAvatarIconsProvider
import org.jetbrains.plugins.github.ui.component.GHHandledErrorPanelModel
@@ -47,6 +47,7 @@ import javax.swing.event.ChangeEvent
internal class GHPRListPanelFactory(private val project: Project,
private val repositoryDataService: GHPRRepositoryDataService,
private val securityService: GHPRSecurityService,
private val listLoader: GHPRListLoader,
private val listUpdatesChecker: GHPRListUpdatesChecker,
private val account: GithubAccount,
@@ -66,9 +67,9 @@ internal class GHPRListPanelFactory(private val project: Project,
}
}
ListEmptyTextController(scope, listLoader, searchVm.searchState, list.emptyText, disposable)
ListEmptyTextController(scope, listLoader, searchVm, list.emptyText, disposable)
val searchPanel = GHPRSearchPanelFactory(searchVm).create(scope)
val searchPanel = GHPRSearchPanelFactory(searchVm).create(scope, createQuickFilters())
val outdatedStatePanel = JPanel(FlowLayout(FlowLayout.LEFT, JBUIScale.scale(5), 0)).apply {
background = UIUtil.getPanelBackground()
@@ -111,6 +112,15 @@ internal class GHPRListPanelFactory(private val project: Project,
}
}
private fun createQuickFilters() = listOf(
GithubBundle.message("pull.request.list.filter.quick.open") to
GHPRListSearchValue(state = GHPRListSearchValue.State.OPEN),
GithubBundle.message("pull.request.list.filter.quick.yours") to
GHPRListSearchValue(state = GHPRListSearchValue.State.OPEN, author = securityService.currentUser.login),
GithubBundle.message("pull.request.list.filter.quick.assigned") to
GHPRListSearchValue(state = GHPRListSearchValue.State.OPEN, assignee = securityService.currentUser.login)
)
private fun createListLoaderPanel(loader: GHListLoader<*>, list: JComponent, disposable: Disposable): JComponent {
val scrollPane = ScrollPaneFactory.createScrollPane(list, true).apply {
@@ -149,13 +159,13 @@ internal class GHPRListPanelFactory(private val project: Project,
private class ListEmptyTextController(scope: CoroutineScope,
private val listLoader: GHListLoader<*>,
private val searchState: MutableStateFlow<GHPRListSearchValue>,
private val searchVm: GHPRSearchPanelViewModel,
private val emptyText: StatusText,
listenersDisposable: Disposable) {
init {
listLoader.addLoadingStateChangeListener(listenersDisposable, ::update)
scope.launch {
searchState.collect {
searchVm.searchState.collect {
update()
}
}
@@ -166,15 +176,15 @@ internal class GHPRListPanelFactory(private val project: Project,
if (listLoader.loading || listLoader.error != null) return
val search = searchState.value
val search = searchVm.searchState.value
if (search == GHPRListSearchValue.DEFAULT) {
emptyText.appendText(GithubBundle.message("pull.request.list.no.matches"))
.appendSecondaryText(GithubBundle.message("pull.request.list.reset.filters"),
SimpleTextAttributes.LINK_ATTRIBUTES) {
searchState.update { GHPRListSearchValue.EMPTY }
searchVm.searchState.update { GHPRListSearchValue.EMPTY }
}
}
else if (search.isEmpty) {
else if (search.filterCount == 0) {
emptyText.appendText(GithubBundle.message("pull.request.list.nothing.loaded"))
}
else {
@@ -182,7 +192,7 @@ internal class GHPRListPanelFactory(private val project: Project,
.appendSecondaryText(GithubBundle.message("pull.request.list.reset.filters.to.default",
GHPRListSearchValue.DEFAULT.toQuery().toString()),
SimpleTextAttributes.LINK_ATTRIBUTES) {
searchState.update { GHPRListSearchValue.DEFAULT }
searchVm.searchState.update { GHPRListSearchValue.DEFAULT }
}
}
}
@@ -3,6 +3,7 @@ package org.jetbrains.plugins.github.pullrequest.ui.toolwindow
import com.intellij.collaboration.ui.codereview.list.search.ReviewListSearchValue
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.jetbrains.plugins.github.api.data.GithubIssueState
import org.jetbrains.plugins.github.pullrequest.data.GHPRSearchQuery
import org.jetbrains.plugins.github.pullrequest.data.GHPRSearchQuery.QualifierName
@@ -17,7 +18,20 @@ internal data class GHPRListSearchValue(override val searchQuery: String? = null
val reviewState: ReviewState? = null,
val author: String? = null,
val label: String? = null) : ReviewListSearchValue {
override val isEmpty = searchQuery == null && state == null && assignee == null && reviewState == null && author == null && label == null
@Transient
override val filterCount: Int = calcFilterCount()
private fun calcFilterCount(): Int {
var count = 0
if (searchQuery != null) count++
if (state != null) count++
if (assignee != null) count++
if (reviewState != null) count++
if (author != null) count++
if (label != null) count++
return count
}
fun toQuery(): GHPRSearchQuery? {
val terms = mutableListOf<Term<*>>()
@@ -14,7 +14,7 @@ internal class GHPRSearchPanelViewModel(
private val repositoryDataService: GHPRRepositoryDataService,
historyViewModel: GHPRSearchHistoryModel,
val avatarIconsProvider: GHAvatarIconsProvider
) : ReviewListSearchPanelViewModelBase<GHPRListSearchValue>(scope, historyViewModel, GHPRListSearchValue.DEFAULT) {
) : ReviewListSearchPanelViewModelBase<GHPRListSearchValue>(scope, historyViewModel, GHPRListSearchValue.EMPTY, GHPRListSearchValue.DEFAULT) {
override fun GHPRListSearchValue.withQuery(query: String?) = copy(searchQuery = query)
@@ -342,6 +342,7 @@ internal class GHPRToolWindowTabControllerImpl(private val project: Project,
return GHPRListPanelFactory(project,
dataContext.repositoryDataService,
dataContext.securityService,
dataContext.listLoader,
dataContext.listUpdatesChecker,
dataContext.securityService.account,