Skip to content

Commit

Permalink
Context menu, copy button, multi-component actions
Browse files Browse the repository at this point in the history
- The 'More' menu can now be opened under the mouse, through the context menu
  action (right click/control-click on Mac/menu button on keyboard).
- Add copy-components button to menu.
- The menu can now be opened while multiple components are selected; if the
  clicked component was among the selected components, the selection will be
  preserved. Some menu actions--currently *copy* and *delete*, apply to all
  selected components. These actions will change their displayed labels when
  multiple components are selected. If a single-component action is executed,
  the component it was applied to will become the sole selection.
  • Loading branch information
kazcw committed Nov 27, 2024
1 parent 88ba6fa commit 1a86a5e
Show file tree
Hide file tree
Showing 35 changed files with 968 additions and 410 deletions.
16 changes: 15 additions & 1 deletion app/common/src/utilities/data/iter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,17 @@ export function map<T, U>(it: Iterable<T>, f: (value: T) => U): IterableIterator
return mapIterator(it[Symbol.iterator](), f)
}

export function filter<T, S extends T>(
iter: Iterable<T>,
include: (value: T) => value is S,
): IterableIterator<S>
export function filter<T>(iter: Iterable<T>, include: (value: T) => boolean): IterableIterator<T>
/**
* Return an {@link Iterable} that `yield`s only the values from the given source iterable
* that pass the given predicate.
*/
export function filter<T>(iter: Iterable<T>, include: (value: T) => boolean): IterableIterator<T> {
return iteratorFilter(iter[Symbol.iterator](), include)
return iteratorFilter(iter[Symbol.iterator](), include) as any
}

/**
Expand Down Expand Up @@ -179,6 +184,15 @@ export function every<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
return true
}

/**
* Returns whether the predicate returned `true` for any values yielded by the provided iterator. Short-circuiting.
* Returns `false` if the iterator doesn't yield any values.
*/
export function any<T>(iter: Iterable<T>, f: (value: T) => boolean): boolean {
for (const value of iter) if (f(value)) return true
return false
}

/** Return the first element returned by the iterable which meets the condition. */
export function find<T>(iter: Iterable<T>, f: (value: T) => boolean): T | undefined {
for (const value of iter) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, type Page } from '@playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { CONTROL_KEY } from './keyboard'
import * as locate from './locate'
import { edgesToNodeWithBinding, graphNodeByBinding, outputPortCoordinates } from './locate'

Expand Down Expand Up @@ -97,8 +98,7 @@ test('Conditional ports: Enabled', async ({ page }) => {
const node = graphNodeByBinding(page, 'filtered')
const conditionalPort = node.locator('.WidgetPort').filter({ hasText: /^filter$/ })

await page.keyboard.down('Meta')
await page.keyboard.down('Control')
await page.keyboard.down(CONTROL_KEY)

await expect(conditionalPort).toHaveClass(/enabled/)
const outputPort = await outputPortCoordinates(graphNodeByBinding(page, 'final'))
Expand All @@ -109,6 +109,5 @@ test('Conditional ports: Enabled', async ({ page }) => {
await conditionalPort.click({ force: true })
await expect(node.locator('.WidgetToken')).toHaveText(['final'])

await page.keyboard.up('Meta')
await page.keyboard.up('Control')
await page.keyboard.up(CONTROL_KEY)
})
56 changes: 48 additions & 8 deletions app/gui/integration-test/project-view/nodeClipboard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import test from 'playwright/test'
import test, { type Locator, type Page } from 'playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { CONTROL_KEY } from './keyboard'
Expand Down Expand Up @@ -28,7 +28,23 @@ test.beforeEach(async ({ page }) => {
})
})

test('Copy node with comment', async ({ page }) => {
test('Copy component with context menu', async ({ page }) => {
await actions.goToGraph(page)
const originalNodes = await locate.graphNode(page).count()
const nodeToCopy = locate.graphNodeByBinding(page, 'final')
await nodeToCopy.click({ button: 'right' })
await expect(nodeToCopy).toBeSelected()
await page
.locator('.ComponentContextMenu')
.getByRole('button', { name: 'Copy Component' })
.click()
await page.keyboard.press(`${CONTROL_KEY}+V`)
await expect(nodeToCopy).not.toBeSelected()
await expect(locate.selectedNodes(page)).toHaveCount(1)
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1)
})

test('Copy component with comment', async ({ page }) => {
await actions.goToGraph(page)

// Check state before operation.
Expand All @@ -51,7 +67,10 @@ test('Copy node with comment', async ({ page }) => {
await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1)
})

test('Copy multiple nodes', async ({ page }) => {
async function testCopyMultiple(
page: Page,
copyNodes: (node1: Locator, node2: Locator) => Promise<void>,
) {
await actions.goToGraph(page)

// Check state before operation.
Expand All @@ -61,13 +80,10 @@ test('Copy multiple nodes', async ({ page }) => {

// Select some nodes.
const node1 = locate.graphNodeByBinding(page, 'final')
await node1.click()
const node2 = locate.graphNodeByBinding(page, 'prod')
await node2.click({ modifiers: ['Shift'] })
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()

// Copy and paste.
await page.keyboard.press(`${CONTROL_KEY}+C`)
await copyNodes(node1, node2)
await page.keyboard.press(`${CONTROL_KEY}+V`)
await expect(node1).not.toBeSelected()
await expect(node2).not.toBeSelected()
Expand All @@ -87,4 +103,28 @@ test('Copy multiple nodes', async ({ page }) => {
await expect(await edgesToNodeWithBinding(page, 'final')).toHaveCount(1 * EDGE_PARTS)
await expect(await edgesToNodeWithBinding(page, 'prod1')).toHaveCount(1 * EDGE_PARTS)
await expect(await edgesToNodeWithBinding(page, 'final1')).toHaveCount(1 * EDGE_PARTS)
}

test('Copy multiple components with keyboard shortcut', async ({ page }) => {
await testCopyMultiple(page, async (node1, node2) => {
await node1.click()
await node2.click({ modifiers: ['Shift'] })
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()
await page.keyboard.press(`${CONTROL_KEY}+C`)
})
})

test('Copy multiple components with context menu', async ({ page }) => {
await testCopyMultiple(page, async (node1, node2) => {
await node1.click()
await node2.click({ modifiers: ['Shift'] })
await expect(node1).toBeSelected()
await expect(node2).toBeSelected()
await node1.click({ button: 'right' })
await page
.locator('.ComponentContextMenu')
.getByRole('button', { name: 'Copy Selected Components' })
.click()
})
})
25 changes: 25 additions & 0 deletions app/gui/integration-test/project-view/nodeComments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ test('Start editing comment via menu', async ({ page }) => {
await expect(locate.nodeComment(node)).toBeFocused()
})

test('Start editing comment via context menu', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNodeByBinding(page, 'final')
await node.click({ button: 'right' })
await page.getByRole('button', { name: 'Add Comment' }).click()
await expect(locate.nodeComment(node)).toBeFocused()
})

test('Start editing comment via context menu when multiple components initially selected', async ({
page,
}) => {
await actions.goToGraph(page)
const otherNode = locate.graphNodeByBinding(page, 'sum')
await otherNode.click()
const node = locate.graphNodeByBinding(page, 'final')
await node.click({ modifiers: ['Shift'] })
const anotherNode = locate.graphNodeByBinding(page, 'list')
await anotherNode.click({ modifiers: ['Shift'] })
await node.click({ button: 'right' })
await expect(locate.selectedNodes(page)).toHaveCount(3)
await page.getByRole('button', { name: 'Add Comment' }).click()
await expect(locate.selectedNodes(page)).toHaveCount(1)
await expect(locate.nodeComment(node)).toBeFocused()
})

test('Add new comment via menu', async ({ page }) => {
await actions.goToGraph(page)
const INITIAL_NODE_COMMENTS = 1
Expand Down
25 changes: 25 additions & 0 deletions app/gui/integration-test/project-view/removingNodes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,31 @@ test('Deleting selected node with delete key', async ({ page }) => {
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
})

test('Deleting node with context menu', async ({ page }) => {
await actions.goToGraph(page)
const nodesCount = await locate.graphNode(page).count()
const deletedNode = locate.graphNodeByBinding(page, 'final')
await deletedNode.click({ button: 'right' })
await expect(locate.selectedNodes(page)).toHaveCount(1)
await page.getByRole('button', { name: 'Delete Component' }).click()
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 1)
})

test('Deleting multiple nodes with context menu', async ({ page }) => {
await actions.goToGraph(page)
const nodesCount = await locate.graphNode(page).count()
const deletedNode1 = locate.graphNodeByBinding(page, 'final')
await deletedNode1.click()
const deletedNode2 = locate.graphNodeByBinding(page, 'sum')
await deletedNode2.click({ modifiers: ['Shift'] })
await deletedNode2.click({ button: 'right' })
await page
.locator('.ComponentContextMenu')
.getByRole('button', { name: 'Delete Selected Components' })
.click()
await expect(locate.graphNode(page)).toHaveCount(nodesCount - 2)
})

test('Graph can be empty', async ({ page }) => {
await actions.goToGraph(page)

Expand Down
2 changes: 1 addition & 1 deletion app/gui/src/project-view/components/ColorRing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const FIXED_RANGE_WIDTH = 1 / 16
const selectedColor = defineModel<string | undefined>()
const props = defineProps<{
matchableColors: Set<string>
matchableColors: ReadonlySet<string>
/** Angle, measured in degrees from the positive Y-axis, where the initially-selected color should be placed. */
initialColorAngle?: number
}>()
Expand Down
45 changes: 45 additions & 0 deletions app/gui/src/project-view/components/ComponentActionButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<script setup lang="ts">
import MenuButton from '@/components/MenuButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { type ComponentAction } from '@/util/componentActions'
const { action } = defineProps<{ action: ComponentAction }>()
</script>

<template>
<!-- eslint-disable vue/no-mutating-props -- `ComponentAction` is an internally-reactive object. -->
<MenuButton
:data-testid="action.testid"
:disabled="action.disabled"
class="ComponentActionButton"
v-bind="action.state != null ? { modelValue: action.state } : {}"
@update:modelValue="action.state != null && (action.state = $event)"
@click="action.action"
>
<!-- eslint-enable vue/no-mutating-props -->
<SvgIcon :name="action.icon" class="rowIcon" />
<span v-text="action.description" />
<span v-if="action.shortcut" class="shortcutHint" v-text="action.shortcut" />
</MenuButton>
</template>

<style scoped>
.ComponentActionButton {
display: flex;
align-items: center;
justify-content: left;
padding-left: 8px;
padding-right: 8px;
}
.rowIcon {
display: inline-block;
margin-right: 8px;
}
.shortcutHint {
margin-left: auto;
padding-left: 2em;
opacity: 0.8;
}
</style>
49 changes: 49 additions & 0 deletions app/gui/src/project-view/components/ComponentContextMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<script setup lang="ts">
import ComponentActionButton from '@/components/ComponentActionButton.vue'
import MenuPanel from '@/components/MenuPanel.vue'
import { injectSelectionActions } from '@/providers/selectionActions'
import { injectComponentActions } from '@/util/componentActions'
const emit = defineEmits<{ close: [] }>()
const componentActions = injectComponentActions()
const { actions: selectionActions } = injectSelectionActions()
const componentActionButtons: (keyof typeof componentActions)[] = [
'toggleDocPanel',
'toggleVisualization',
'createNewNode',
'editingComment',
'recompute',
'pickColor',
'enterNode',
'startEditing',
]
const selectionActionButtons: (keyof typeof selectionActions)[] = ['copy', 'deleteSelected']
</script>

<template>
<MenuPanel class="ComponentContextMenu" @contextmenu.stop.prevent="emit('close')">
<ComponentActionButton
v-for="action in componentActionButtons"
:key="`component:${action}`"
:action="componentActions[action]"
@click.stop="emit('close')"
/>
<ComponentActionButton
v-for="action in selectionActionButtons"
:key="`selection:${action}`"
:action="selectionActions[action]"
@click.stop="emit('close')"
/>
</MenuPanel>
</template>

<style scoped>
.MenuPanel {
margin-top: 2px;
padding: 4px;
background: var(--dropdown-opened-background, var(--color-app-bg));
backdrop-filter: var(--dropdown-opened-backdrop-filter, var(--blur-app-bg));
}
</style>
Loading

0 comments on commit 1a86a5e

Please sign in to comment.