Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add actions to copy logical / physical names to stack viewer #2165

Merged
merged 10 commits into from
Oct 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,4 @@
{
"type" : "feature",
"description" : "Add copy Logical/Physical ID actions to Stack View #2165"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package software.aws.toolkits.jetbrains.services.cloudformation.stack

import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.table.JBTable
import java.awt.event.MouseListener
import javax.swing.JComponent
import javax.swing.SwingUtilities
import javax.swing.table.DefaultTableModel
Expand Down Expand Up @@ -43,6 +44,16 @@ class DynamicTableView<T>(private vararg val fields: Field<T>) : View {
table.setPaintBusy(busy)
}

fun addMouseListener(listener: MouseListener) = table.addMouseListener(listener)

fun selectedRow(): Map<Field<T>, Any?>? {
val row = table.selectedRows?.takeIf { it.size == 1 }?.firstOrNull() ?: return null
return (0 until model.columnCount).map { col ->
val field = fields.find { field -> field.readableName == model.getColumnName(col) } ?: return null
field to model.getValueAt(row, col)
}.toMap()
}

data class Field<T>(
val readableName: String,
val renderer: TableCellRenderer? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,32 @@ class StatusCellRenderer : DefaultTableCellRenderer() {

internal class EventsTableImpl : EventsTable, Disposable {

private val table = DynamicTableView<StackEvent>(
private val logicalId = DynamicTableView.Field<StackEvent>(message("cloudformation.stack.logical_id")) { e -> e.logicalResourceId() }
private val physicalId = DynamicTableView.Field<StackEvent>(message("cloudformation.stack.physical_id")) { e -> e.physicalResourceId() }

private val table = DynamicTableView(
DynamicTableView.Field(message("general.time")) { e -> e.timestamp() },
// CFN Resource Status does not match what we expect (StackStatus enum)
DynamicTableView.Field(message("cloudformation.stack.status"), renderer = StatusCellRenderer()) { e -> e.resourceStatusAsString() },
DynamicTableView.Field(message("cloudformation.stack.logical_id")) { e -> e.logicalResourceId() },
DynamicTableView.Field(message("cloudformation.stack.physical_id")) { e -> e.physicalResourceId() },
logicalId,
physicalId,
DynamicTableView.Field(
message("cloudformation.stack.reason"),
WrappingCellRenderer(wrapOnSelection = true, wrapOnToggle = false)
) { e -> e.resourceStatusReason() ?: "" }
).apply { component.border = IdeBorderFactory.createBorder(SideBorder.BOTTOM) }

init {
table.addMouseListener(ResourceActionPopup(this::selected))
}

private fun selected(): SelectedResource? {
val row = table.selectedRow() ?: return null
val logicalId = row[logicalId] as? String ?: return null
val physicalId = row[physicalId] as? String
return SelectedResource(logicalId, physicalId?.takeIf { it.isNotBlank() })
}

override val component: JComponent = table.component

override fun showBusyIcon() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.cloudformation.stack

import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DefaultActionGroup
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.project.DumbAware
import com.intellij.ui.PopupHandler
import software.aws.toolkits.resources.message
import java.awt.Component
import java.awt.datatransfer.StringSelection

internal class ResourceActionPopup(private val selected: () -> SelectedResource?) : PopupHandler() {
private val actionManager = ActionManager.getInstance()
override fun invokePopup(comp: Component?, x: Int, y: Int) {
val selected = selected() ?: return
val actionGroup = DefaultActionGroup(
listOf(
CopyAction(message("cloudformation.stack.logical_id.copy"), selected.logicalId),
CopyAction(message("cloudformation.stack.physical_id.copy"), selected.physicalId)
)
)
val popupMenu = actionManager.createActionPopupMenu(STACK_TOOL_WINDOW.id, actionGroup)
popupMenu.component.show(comp, x, y)
}
}

private class CopyAction(name: String, private val value: String?) : AnAction(name, null, AllIcons.Actions.Copy), DumbAware {

override fun update(e: AnActionEvent) {
e.presentation.isEnabledAndVisible = value != null
}

override fun actionPerformed(e: AnActionEvent) {
CopyPasteManager.getInstance().setContents(StringSelection(value))
}
}

internal data class SelectedResource(internal val logicalId: String, internal val physicalId: String?)
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import software.aws.toolkits.resources.message
import javax.swing.JComponent

class ResourceTableView : View, ResourceListener, Disposable {
private val table = DynamicTableView<StackResource>(
DynamicTableView.Field(message("cloudformation.stack.logical_id")) { it.logicalResourceId() },
DynamicTableView.Field(message("cloudformation.stack.physical_id")) { it.physicalResourceId() },
private val logicalId = DynamicTableView.Field<StackResource>(message("cloudformation.stack.logical_id")) { it.logicalResourceId() }
private val physicalId = DynamicTableView.Field<StackResource>(message("cloudformation.stack.physical_id")) { it.physicalResourceId() }

private val table = DynamicTableView(
logicalId,
physicalId,
DynamicTableView.Field(message("cloudformation.stack.type")) { it.resourceType() },
DynamicTableView.Field(message("cloudformation.stack.status"), renderer = StatusCellRenderer()) { it.resourceStatusAsString() },
DynamicTableView.Field(
Expand All @@ -22,6 +25,17 @@ class ResourceTableView : View, ResourceListener, Disposable {
) { it.resourceStatusReason() }
).apply { component.border = JBUI.Borders.empty() }

init {
table.addMouseListener(ResourceActionPopup(this::selected))
}

private fun selected(): SelectedResource? {
val row = table.selectedRow() ?: return null
val logicalId = row[logicalId] as? String ?: return null
val physicalId = row[physicalId] as? String
return SelectedResource(logicalId, physicalId?.takeIf { it.isNotBlank() })
}

override val component: JComponent = table.component

override fun updatedResources(resources: List<StackResource>) = table.updateItems(resources, clearExisting = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T
val resource = nameAndResource.value

val status = resource.resourceStatus()
val newDescriptor = StackNodeDescriptor(project, name, status.type, status.name, rootDescriptor)
val newDescriptor = StackNodeDescriptor(project, name, status.type, status.name, rootDescriptor, physicalId = resource.physicalResourceId())
rootNode.add(DefaultMutableTreeNode(newDescriptor, false))
}
}
Expand All @@ -89,11 +89,21 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T
val descriptor = StackNodeDescriptor(project, stackName, StatusType.UNKNOWN, message("loading_resource.loading"))
val rootNode = DefaultMutableTreeNode(descriptor, true)
model = DefaultTreeModel(rootNode)
tree = Tree(model)
tree = Tree(model).also { it.name = "$stackName.tree" }
tree.addMouseListener(ResourceActionPopup(this::selected))
tree.setPaintBusy(true)
component = JBScrollPane(tree)
}

private fun selected(): SelectedResource? {
val node = tree.selectionPaths ?: return null
if (node.size != 1) {
return null
}
val selectedNode = (node.first().lastPathComponent as? DefaultMutableTreeNode)?.userObject as? StackNodeDescriptor ?: return null
return SelectedResource(selectedNode.name, selectedNode.physicalId)
}

override fun getIconsAndUpdaters() =
(StreamEx.of(rootNode.children()) + listOf(rootNode))
.filterIsInstance<DefaultMutableTreeNode>()
Expand All @@ -119,10 +129,11 @@ internal class TreeViewImpl(private val project: Project, stackName: String) : T

private class StackNodeDescriptor(
project: Project,
name: String,
val name: String,
private var statusType: StatusType,
private var status: String,
parent: StackNodeDescriptor? = null
parent: StackNodeDescriptor? = null,
var physicalId: String? = null
) : NodeDescriptor<String>(project, parent) {

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,13 @@ cloudformation.key_not_found={0} not found on {1}
cloudformation.missing_property=Property {0} not found in {1}
cloudformation.stack.delete.action=Delete Stack...
cloudformation.stack.logical_id=Logical ID
cloudformation.stack.logical_id.copy=Copy Logical ID
cloudformation.stack.outputs.description=Description
cloudformation.stack.outputs.export=Export Name
cloudformation.stack.outputs.key=Key
cloudformation.stack.outputs.value=Value
cloudformation.stack.physical_id=Physical ID
cloudformation.stack.physical_id.copy=Copy Physical ID
cloudformation.stack.reason=Status Reason
cloudformation.stack.status=Status
cloudformation.stack.tab_labels.events=Events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ package software.aws.toolkits.jetbrains.uitests.tests

import com.intellij.remoterobot.stepsProcessing.log
import com.intellij.remoterobot.stepsProcessing.step
import com.intellij.remoterobot.utils.attempt
import com.intellij.remoterobot.utils.waitFor
import com.intellij.remoterobot.utils.waitForIgnoringError
import org.assertj.core.api.AbstractStringAssert
import org.assertj.core.api.Assertions.assertThat
import org.assertj.swing.core.MouseButton
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
Expand All @@ -26,6 +29,8 @@ import software.aws.toolkits.jetbrains.uitests.fixtures.findAndClick
import software.aws.toolkits.jetbrains.uitests.fixtures.idea
import software.aws.toolkits.jetbrains.uitests.fixtures.pressOk
import software.aws.toolkits.jetbrains.uitests.fixtures.welcomeFrame
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.nio.file.Path
import java.nio.file.Paths
import java.time.Duration
Expand All @@ -38,6 +43,7 @@ class CloudFormationBrowserTest {
private val stack = "uitest-${UUID.randomUUID()}"

private val CloudFormation = "CloudFormation"
private val queueName = "SQSQueue"
private val deleteStackText = "Delete Stack..."

@TempDir
Expand Down Expand Up @@ -72,11 +78,48 @@ class CloudFormationBrowserTest {
doubleClickExplorer(CloudFormation, stack)
}
}
step("Can copy IDs from tree") {
val queueNode = step("Finding '$queueName [CREATE_COMPLETE]'") {
attempt(5) {
findText("$queueName [CREATE_COMPLETE]")
}
}
step("Logical ID") {
queueNode.click(MouseButton.RIGHT_BUTTON)
findAndClick("//div[@text='Copy Logical ID']")

assertClipboardContents().isEqualTo(queueName)
}
step("Physical ID") {
queueNode.click(MouseButton.RIGHT_BUTTON)
findAndClick("//div[@text='Copy Physical ID']")
assertClipboardContents().startsWith("https").contains(queueName)
}
}
step("Check events") {
clickOn("Events")
step("Assert that there are two CREATE_COMPLETE events shown") {
val resource = step("Assert that there are two CREATE_COMPLETE events shown") {
val createComplete = findAllText("CREATE_COMPLETE")
assertThat(createComplete).hasSize(2)
createComplete.first()
}

step("Check Logical ID action") {
resource.click(MouseButton.RIGHT_BUTTON)
findAndClick("//div[@text='Copy Logical ID']")
assertClipboardContents().satisfiesAnyOf(
{ assertThat(it).isEqualTo(queueName) },
{ assertThat(it).startsWith("uitest") }
)
}
step("Check Physical ID action") {
resource.click(MouseButton.RIGHT_BUTTON)
findAndClick("//div[@text='Copy Physical ID']")

assertClipboardContents().satisfiesAnyOf(
{ assertThat(it).startsWith("https").contains(queueName) },
{ assertThat(it).startsWith("arn") }
)
}
}
step("Check outputs") {
Expand All @@ -87,9 +130,23 @@ class CloudFormationBrowserTest {
}
step("Check resources") {
clickOn("Resources")
step("Assert that the stack resource is there") {
val resource = step("Assert that the stack resource is there") {
val createComplete = findAllText("CREATE_COMPLETE")
assertThat(createComplete).hasSize(1)
createComplete.first()
}

step("Check Logical ID action") {
resource.click(MouseButton.RIGHT_BUTTON)
findAndClick("//div[@text='Copy Logical ID']")

assertClipboardContents().isEqualTo(queueName)
}
step("Check Physical ID action") {
resource.click(MouseButton.RIGHT_BUTTON)
findAndClick("//div[@text='Copy Physical ID']")

assertClipboardContents().startsWith("https").contains(queueName)
}
}
step("Delete stack $stack") {
Expand Down Expand Up @@ -123,16 +180,12 @@ class CloudFormationBrowserTest {
waitForStackDeletion()
}

private fun assertClipboardContents(): AbstractStringAssert<*> =
assertThat(Toolkit.getDefaultToolkit().systemClipboard.getData(DataFlavor.stringFlavor) as String)

private fun IdeaFrame.clickOn(tab: String) {
findAndClick("//div[@accessiblename='$tab' and @class='JLabel' and @text='$tab']")
}
private fun IdeaFrame.clickOnOutputs() {
findAndClick("//div[@accessiblename='Outputs' and @class='JLabel' and @text='Outputs']")
}

private fun IdeaFrame.clickOnEvents() {
findAndClick("//div[@accessiblename='Events' and @class='JLabel' and @text='Events']")
}

private fun waitForStackDeletion() {
log.info("Waiting for the deletion of stack $stack")
Expand Down