Copying and pasting in Grid fixes. (#11332)

Fixes #10760
Fixes #11141

[Screencast From 2024-10-17 13-51-45.webm](https://github.com/user-attachments/assets/78924f87-6bdb-4cb6-8f95-d9f97c63aea4)

This PR also changes the name of new columns to `Column #n` where n is index of column - this way it's easier to quickly create tables with non-conflicting column names.
This commit is contained in:
Adam Obuchowicz 2024-10-21 09:27:46 +02:00 committed by GitHub
parent 1fbaeaa767
commit fa87a1857a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 515 additions and 140 deletions

View File

@ -6,9 +6,11 @@
is available in right-click context menu.
- [Rows and Columns may be now reordered by dragging in Table Input
Widget][11271]
- [Copying and pasting in Table Editor Widget now works properly][11332]
[11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271
[11332]: https://github.com/enso-org/enso/pull/11332
#### Enso Standard Library

View File

@ -1,5 +1,6 @@
import { type Page } from '@playwright/test'
import { expect } from './customExpect'
import { mockMethodCallInfo } from './expressionUpdates'
import * as locate from './locate'
import { graphNodeByBinding } from './locate'
@ -50,9 +51,9 @@ export async function exitFunction(page: Page, x = 300, y = 300) {
await locate.graphEditor(page).dblclick({ position: { x, y } })
}
// =================
// === Drag Node ===
// =================
// =============
// === Graph ===
// =============
/** Move node defined by the given binding by the given x and y. */
export async function dragNodeByBinding(page: Page, nodeBinding: string, x: number, y: number) {
@ -75,3 +76,62 @@ export async function deselectNodes(page: Page) {
await page.mouse.click(0, 0)
await expect(locate.selectedNodes(page)).toHaveCount(0)
}
/** Click at spot where no node is. */
export function clickAtBackground(page: Page, x = 300, y = 300) {
return locate.graphEditor(page).click({ position: { x, y } })
}
// ======================
// === Visualizations ===
// ======================
/**
* Open Table visualization
*
* This action ensures the table visualization is opened somewhere in the graph; currently it opens
* visualization for `aggregate` node.
*/
export async function openVisualization(page: Page, visName: string) {
const aggregatedNode = graphNodeByBinding(page, 'aggregated')
await aggregatedNode.click()
await page.keyboard.press('Space')
await locate.toggleVisualizationSelectorButton(page).click()
await page.locator('.VisualizationSelector').getByRole('button', { name: visName }).click()
}
// ===============
// === Widgets ===
// ===============
/**
* Create a Node with Table Input Widget.
*
* This function relies on automatically assigned binding and assome no more table nodes exist.
*/
export async function createTableNode(page: Page) {
// Adding `Table.new` component will display the widget
await locate.addNewNodeButton(page).click()
await expect(locate.componentBrowser(page)).toBeVisible()
await page.keyboard.type('Table.new')
// Wait for CB entry to appear; this way we're sure about node name (binding).
await expect(locate.componentBrowserSelectedEntry(page)).toHaveCount(1)
await expect(locate.componentBrowserSelectedEntry(page)).toHaveText('Table.new')
await page.keyboard.press('Enter')
const node = locate.graphNodeByBinding(page, 'table1')
await expect(node).toHaveCount(1)
await expect(node).toBeVisible()
await mockMethodCallInfo(
page,
{ binding: 'table1', expr: 'Table.new' },
{
methodPointer: {
module: 'Standard.Table.Table',
definedOnType: 'Standard.Table.Table.Table',
name: 'new',
},
notAppliedArguments: [0],
},
)
return node
}

View File

@ -57,12 +57,7 @@ test('Warnings visualization', async ({ page }) => {
await input.fill('Warning.attach "Uh oh" 42')
await page.keyboard.press('Enter')
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.circularMenu(page)).toExist()
await locate.toggleVisualizationButton(page).click()
await expect(locate.anyVisualization(page)).toExist()
await expect(locate.loadingVisualization(page)).toHaveCount(0)
await locate.toggleVisualizationSelectorButton(page).click()
await page.locator('.VisualizationSelector').getByRole('button', { name: 'Warnings' }).click()
await actions.openVisualization(page, 'Warnings')
await expect(locate.warningsVisualization(page)).toExist()
// Click the remove-warnings button, and ensure a node is created.
const nodeCount = await locate.graphNode(page).count()

View File

@ -33,3 +33,51 @@ test('Load Table Visualisation', async ({ page }) => {
await expect(tableVisualization).toContainText('2,0')
await expect(tableVisualization).toContainText('3,0')
})
test('Copy from Table Visualization', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await actions.goToGraph(page)
actions.openVisualization(page, 'Table')
const tableVisualization = locate.tableVisualization(page)
await expect(tableVisualization).toExist()
await tableVisualization.getByText('0,0').hover()
await page.mouse.down()
await tableVisualization.getByText('2,1').hover()
await page.mouse.up()
await page.keyboard.press('Control+C')
// Paste to Node.
await actions.clickAtBackground(page)
const nodesCount = await locate.graphNode(page).count()
await page.keyboard.press('Control+V')
await expect(locate.graphNode(page)).toHaveCount(nodesCount + 1)
await expect(locate.graphNode(page).last().locator('input')).toHaveValue(
'0,0\t0,11,0\t1,12,0\t2,1',
)
// Paste to Table Widget.
const node = await actions.createTableNode(page)
const widget = node.locator('.WidgetTableEditor')
await expect(widget).toBeVisible()
await widget.locator('.ag-cell', { hasNotText: /0/ }).first().click()
await page.keyboard.press('Control+V')
await expect(widget.locator('.ag-cell')).toHaveText([
'0',
'0,0',
'0,1',
'',
'1',
'1,0',
'1,1',
'',
'2',
'2,0',
'2,1',
'',
'3',
'',
'',
'',
])
})

View File

@ -319,7 +319,7 @@ test('Selection widget with text widget as input', async ({ page }) => {
await pathDropdown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2'])
await page.keyboard.insertText('Foo')
await expect(pathArgInput).toHaveValue('Foo')
await page.mouse.click(200, 200)
await actions.clickAtBackground(page)
await expect(pathArgInput).not.toBeFocused()
await expect(pathArgInput).toHaveValue('Foo')
await expect(pathDropdown.dropDown).not.toBeVisible()
@ -347,7 +347,7 @@ test('File Browser widget', async ({ page }) => {
await expect(pathArg.locator('.WidgetText > input')).toHaveValue('/path/to/some/mock/file')
})
test('Managing aggregates in `aggregate` node', async ({ page }) => {
test('Manage aggregates in `aggregate` node', async ({ page }) => {
await actions.goToGraph(page)
await mockMethodCallInfo(page, 'aggregated', {
methodPointer: {
@ -537,29 +537,7 @@ test('Autoscoped constructors', async ({ page }) => {
test('Table widget', async ({ page }) => {
await actions.goToGraph(page)
// Adding `Table.new` component will display the widget
await locate.addNewNodeButton(page).click()
await expect(locate.componentBrowser(page)).toBeVisible()
await page.keyboard.type('Table.new')
// Wait for CB entry to appear; this way we're sure about node name (binding).
await expect(locate.componentBrowserSelectedEntry(page)).toHaveCount(1)
await expect(locate.componentBrowserSelectedEntry(page)).toHaveText('Table.new')
await page.keyboard.press('Enter')
const node = locate.selectedNodes(page)
await expect(node).toHaveCount(1)
await expect(node).toBeVisible()
await mockMethodCallInfo(
page,
{ binding: 'table1', expr: 'Table.new' },
{
methodPointer: {
module: 'Standard.Table.Table',
definedOnType: 'Standard.Table.Table.Table',
name: 'new',
},
notAppliedArguments: [0],
},
)
const node = await actions.createTableNode(page)
const widget = node.locator('.WidgetTableEditor')
await expect(widget).toBeVisible()
await expect(widget.locator('.ag-header-cell-text')).toHaveText(['#', 'New Column'])
@ -575,25 +553,25 @@ test('Table widget', async ({ page }) => {
await page.keyboard.press('Enter')
// There will be new blank column and new blank row allowing adding new columns and rows
// (so 4 cells in total)
await expect(widget.locator('.ag-header-cell-text')).toHaveText(['#', 'New Column', 'New Column'])
await expect(widget.locator('.ag-header-cell-text')).toHaveText(['#', 'Column #0', 'New Column'])
await expect(widget.locator('.ag-cell')).toHaveText(['0', 'Value', '', '1', '', ''])
// Renaming column
await widget.locator('.ag-header-cell-text', { hasText: 'New Column' }).first().click()
await widget.locator('.ag-header-cell-text', { hasText: 'Column #0' }).first().click()
await page.keyboard.type('Header')
await page.keyboard.press('Enter')
await expect(widget.locator('.ag-header-cell-text')).toHaveText(['#', 'Header', 'New Column'])
// Switching edit between cells and headers - check we will never edit two things at once.
await expect(widget.locator('.ag-text-field-input')).toHaveCount(0)
await widget.locator('.ag-header-cell-text', { hasNotText: '#' }).first().click()
await widget.locator('.ag-header-cell-text', { hasNotText: /#/ }).first().click()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-cell', { hasNotText: /0|1/ }).first().click()
await widget.locator('.ag-cell', { hasNotText: /0|1/ }).first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-header-cell-text', { hasNotText: '#' }).first().click()
await widget.locator('.ag-header-cell-text', { hasNotText: /#/ }).first().click()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
// The header after click stops editing immediately. Tracked by #11150
// await widget.locator('.ag-header-cell-text', { hasNotText: '#' }).last().click()
// await widget.locator('.ag-header-cell-text', { hasNotText: /#/ }).last().dblclick()
// await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await page.keyboard.press('Escape')
await expect(widget.locator('.ag-text-field-input')).toHaveCount(0)

View File

@ -64,3 +64,9 @@ export const nodeEditBindings = defineKeybinds('node-edit', {
cancel: ['Escape'],
edit: ['Mod+PointerMain'],
})
export const gridBindings = defineKeybinds('grid', {
cutCells: ['Mod+X'],
copyCells: ['Mod+C'],
pasteCells: ['Mod+V'],
})

View File

@ -22,6 +22,7 @@ import type {
CellEditingStoppedEvent,
Column,
ColumnMovedEvent,
ProcessDataFromClipboardParams,
RowDragEndEvent,
} from 'ag-grid-enterprise'
import { computed, ref } from 'vue'
@ -32,7 +33,7 @@ const graph = useGraphStore()
const suggestionDb = useSuggestionDbStore()
const grid = ref<ComponentExposed<typeof AgGridTableView<RowData, any>>>()
const { rowData, columnDefs, moveColumn, moveRow } = useTableNewArgument(
const { rowData, columnDefs, moveColumn, moveRow, pasteFromClipboard } = useTableNewArgument(
() => props.input,
graph,
suggestionDb.entries,
@ -161,6 +162,20 @@ function onRowDragEnd(event: RowDragEndEvent<RowData>) {
}
}
// === Paste Handler ===
function processDataFromClipboard({ data, api }: ProcessDataFromClipboardParams<RowData>) {
const focusedCell = api.getFocusedCell()
if (focusedCell === null) console.warn('Pasting while no cell is focused!')
else {
pasteFromClipboard(data, {
rowIndex: focusedCell.rowIndex,
colId: focusedCell.column.getColId(),
})
}
return []
}
// === Column Default Definition ===
const defaultColDef = {
@ -204,10 +219,10 @@ export const widgetDefinition = defineWidget(
:rowData="rowData"
:getRowId="(row) => `${row.data.index}`"
:components="{ agColumnHeader: TableHeader }"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
:suppressDragLeaveHidesColumns="true"
:suppressMoveWhenColumnDragging="true"
:processDataFromClipboard="processDataFromClipboard"
@keydown.enter.stop
@keydown.arrow-left.stop
@keydown.arrow-right.stop

View File

@ -1,9 +1,9 @@
import {
MenuItem,
RowData,
tableNewCallMayBeHandled,
useTableNewArgument,
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
import { MenuItem } from '@/components/shared/AgGridTableView.vue'
import { WidgetInput } from '@/providers/widgetRegistry'
import { SuggestionDb } from '@/stores/suggestionDatabase'
import { makeType } from '@/stores/suggestionDatabase/entry'
@ -72,7 +72,7 @@ test.each([
{ '#': 3, a: null, b: null, c: null, d: null, 'New Column': null },
],
},
])('Reading table from $code', ({ code, expectedColumnDefs, expectedRows }) => {
])('Read table from $code', ({ code, expectedColumnDefs, expectedRows }) => {
const ast = Ast.parse(code)
expect(tableNewCallMayBeHandled(ast)).toBeTruthy()
const input = WidgetInput.FromAst(ast)
@ -144,102 +144,107 @@ function tableEditFixture(code: string, expectedCode: string) {
suggestionDbWithNothing(),
onUpdate,
)
return { tableNewArgs, startEdit, onUpdate, addMissingImports }
const gridApi = {
cutToClipboard: vi.fn(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
}
return { tableNewArgs, startEdit, onUpdate, addMissingImports, gridApi }
}
test.each([
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Editing number',
description: 'Edit number',
edit: { column: 1, row: 1, value: -22 },
expected: "Table.new [['a', [1, -22, 3]], ['b', [4, 5, 6]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Editing string',
description: 'Edit string',
edit: { column: 1, row: 1, value: 'two' },
expected: "Table.new [['a', [1, 'two', 3]], ['b', [4, 5, 6]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Putting blank value',
description: 'Put blank value',
edit: { column: 2, row: 1, value: '' },
expected: "Table.new [['a', [1, 2, 3]], ['b', [4, Nothing, 6]]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Adding new column',
description: 'Add new column',
edit: { column: 3, row: 1, value: 8 },
expected:
"Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]], ['New Column', [Nothing, 8, Nothing]]]",
"Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]], ['Column #2', [Nothing, 8, Nothing]]]",
importExpected: true,
},
{
code: 'Table.new []',
description: 'Adding first column',
description: 'Add first column',
edit: { column: 1, row: 0, value: 8 },
expected: "Table.new [['New Column', [8]]]",
expected: "Table.new [['Column #0', [8]]]",
},
{
code: 'Table.new',
description: 'Adding parameter',
description: 'Add parameter',
edit: { column: 1, row: 0, value: 8 },
expected: "Table.new [['New Column', [8]]]",
expected: "Table.new [['Column #0', [8]]]",
},
{
code: 'Table.new _',
description: 'Update parameter',
edit: { column: 1, row: 0, value: 8 },
expected: "Table.new [['New Column', [8]]]",
expected: "Table.new [['Column #0', [8]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Adding new row',
description: 'Add new row',
edit: { column: 1, row: 3, value: 4.5 },
expected: "Table.new [['a', [1, 2, 3, 4.5]], ['b', [4, 5, 6, Nothing]]]",
importExpected: true,
},
{
code: "Table.new [['a', []], ['b', []]]",
description: 'Adding first row',
description: 'Add first row',
edit: { column: 2, row: 0, value: 'val' },
expected: "Table.new [['a', [Nothing]], ['b', ['val']]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Adding new row and column (the cell in the corner)',
description: 'Add new row and column (the cell in the corner)',
edit: { column: 3, row: 3, value: 7 },
expected:
"Table.new [['a', [1, 2, 3, Nothing]], ['b', [4, 5, 6, Nothing]], ['New Column', [Nothing, Nothing, Nothing, 7]]]",
"Table.new [['a', [1, 2, 3, Nothing]], ['b', [4, 5, 6, Nothing]], ['Column #2', [Nothing, Nothing, Nothing, 7]]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, ,3]]]",
description: 'Setting missing value',
description: 'Set missing value',
edit: { column: 1, row: 1, value: 2 },
expected: "Table.new [['a', [1, 2 ,3]]]",
},
{
code: "Table.new [['a', [, 2, 3]]]",
description: 'Setting missing value at first row',
description: 'Set missing value at first row',
edit: { column: 1, row: 0, value: 1 },
expected: "Table.new [['a', [1, 2, 3]]]",
},
{
code: "Table.new [['a', [1, 2,]]]",
description: 'Setting missing value at last row',
description: 'Set missing value at last row',
edit: { column: 1, row: 2, value: 3 },
expected: "Table.new [['a', [1, 2, 3]]]",
},
{
code: "Table.new [['a', [1, 2]], ['a', [3, 4]]]",
description: 'Editing with duplicated column name',
description: 'Edit with duplicated column name',
edit: { column: 1, row: 1, value: 5 },
expected: "Table.new [['a', [1, 5]], ['a', [3, 4]]]",
},
])('Editing table $code: $description', ({ code, edit, expected, importExpected }) => {
])('Edit table $code: $description', ({ code, edit, expected, importExpected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
const editedRow = tableNewArgs.rowData.value[edit.row]
assert(editedRow != null)
@ -255,11 +260,11 @@ test.each([
function getCustomMenuItemByName(
name: string,
items:
| (string | MenuItem)[]
| (string | MenuItem<RowData>)[]
| GetMainMenuItems<RowData>
| GetContextMenuItems<RowData>
| undefined,
): MenuItem | undefined {
): MenuItem<RowData> | undefined {
if (!(items instanceof Array)) return undefined
const found = items.find((item) => typeof item === 'object' && item.name === name)
return typeof found === 'object' ? found : undefined
@ -286,16 +291,15 @@ test.each([
removedRowIndex: 0,
expected: "Table.new [['a', []], ['b', []]]",
},
])('Removing $removedRowIndex row in $code', ({ code, removedRowIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
])('Remove $removedRowIndex row in $code', ({ code, removedRowIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports, gridApi } = tableEditFixture(code, expected)
const removedRow = tableNewArgs.rowData.value[removedRowIndex]
assert(removedRow != null)
// Context menu of all cells in given row should work (even the "virtual" columns).
for (const colDef of tableNewArgs.columnDefs.value) {
const removeAction = getCustomMenuItemByName('Remove Row', colDef.contextMenuItems)
assert(removeAction != null)
removeAction.action({ node: { data: removedRow } })
removeAction.action({ node: { data: removedRow }, api: gridApi })
expect(onUpdate).toHaveBeenCalledOnce()
onUpdate.mockClear()
}
@ -323,18 +327,18 @@ test.each([
removedColIndex: 1,
expected: 'Table.new []',
},
])('Removing $removedColIndex column in $code', ({ code, removedColIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
])('Remove $removedColIndex column in $code', ({ code, removedColIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports, gridApi } = tableEditFixture(code, expected)
const removedCol = tableNewArgs.columnDefs.value[removedColIndex]
assert(removedCol != null)
const removeAction = getCustomMenuItemByName('Remove Column', removedCol.mainMenuItems)
assert(removeAction != null)
removeAction.action({ node: null })
removeAction.action({ node: null, api: gridApi })
expect(onUpdate).toHaveBeenCalledOnce()
onUpdate.mockClear()
const cellRemoveAction = getCustomMenuItemByName('Remove Column', removedCol.contextMenuItems)
cellRemoveAction?.action({ node: { data: tableNewArgs.rowData.value[0] } })
cellRemoveAction?.action({ node: { data: tableNewArgs.rowData.value[0] }, api: gridApi })
expect(onUpdate).toHaveBeenCalledOnce()
expect(addMissingImports).not.toHaveBeenCalled()
@ -354,7 +358,7 @@ test.each([
expected: "Table.new [['a', [1, 2]], ['c', [5, 6]], ['b', [3, 4]]]",
},
])(
'Moving column $fromIndex to $toIndex in table $code',
'Move column $fromIndex to $toIndex in table $code',
({ code, fromIndex, toIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
const movedColumnDef = tableNewArgs.columnDefs.value[fromIndex]
@ -384,7 +388,7 @@ test.each([
toIndex: -1,
expected: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
},
])('Moving row $fromIndex to $toIndex in table $code', ({ code, fromIndex, toIndex, expected }) => {
])('Move row $fromIndex to $toIndex in table $code', ({ code, fromIndex, toIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
tableNewArgs.moveRow(fromIndex, toIndex)
if (code !== expected) {
@ -392,3 +396,122 @@ test.each([
}
expect(addMissingImports).not.toHaveBeenCalled()
})
test.each([
{
code: 'Table.new',
focused: { rowIndex: 0, colIndex: 1 },
data: [
['1', '3'],
['2', '4'],
],
expected: "Table.new [['Column #0', [1, 2]], ['Column #1', [3, 4]]]",
},
{
code: 'Table.new []',
focused: { rowIndex: 0, colIndex: 1 },
data: [
['1', '3'],
['2', '4'],
],
expected: "Table.new [['Column #0', [1, 2]], ['Column #1', [3, 4]]]",
},
{
code: 'Table.new []',
focused: { rowIndex: 0, colIndex: 1 },
data: [['a single cell']],
expected: "Table.new [['Column #0', ['a single cell']]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 0, colIndex: 1 },
data: [['a single cell']],
expected: "Table.new [['a', ['a single cell', 2]], ['b', [3, 4]]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 1, colIndex: 2 },
data: [['a single cell']],
expected: "Table.new [['a', [1, 2]], ['b', [3, 'a single cell']]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 2, colIndex: 2 },
data: [['a single cell']],
expected: "Table.new [['a', [1, 2, Nothing]], ['b', [3, 4, 'a single cell']]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 1, colIndex: 3 },
data: [['a single cell']],
expected: "Table.new [['a', [1, 2]], ['b', [3, 4]], ['Column #2', [Nothing, 'a single cell']]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 0, colIndex: 1 },
data: [
['5', '7'],
['6', '8'],
],
expected: "Table.new [['a', [5, 6]], ['b', [7, 8]]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 1, colIndex: 1 },
data: [
['5', '7'],
['6', '8'],
],
expected: "Table.new [['a', [1, 5, 6]], ['b', [3, 7, 8]]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 0, colIndex: 2 },
data: [
['5', '7'],
['6', '8'],
],
expected: "Table.new [['a', [1, 2]], ['b', [5, 6]], ['Column #2', [7, 8]]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 1, colIndex: 2 },
data: [
['5', '7'],
['6', '8'],
],
expected:
"Table.new [['a', [1, 2, Nothing]], ['b', [3, 5, 6]], ['Column #2', [Nothing, 7, 8]]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]]]",
focused: { rowIndex: 2, colIndex: 3 },
data: [
['5', '7'],
['6', '8'],
],
expected:
"Table.new [['a', [1, 2, Nothing, Nothing]], ['b', [3, 4, Nothing, Nothing]], ['Column #2', [Nothing, Nothing, 5, 6]], ['Column #3', [Nothing, Nothing, 7, 8]]]",
importExpected: true,
},
])(
'Paste data $data to table $code at $focused',
({ code, focused, data, expected, importExpected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
const focusedCol = tableNewArgs.columnDefs.value[focused.colIndex]
console.log(focusedCol?.colId, focusedCol?.headerName)
assert(focusedCol?.colId != null)
tableNewArgs.pasteFromClipboard(data, {
rowIndex: focused.rowIndex,
colId: focusedCol.colId as Ast.AstId,
})
if (code !== expected) {
expect(onUpdate).toHaveBeenCalledOnce()
}
if (importExpected) expect(addMissingImports).toHaveBeenCalled()
else expect(addMissingImports).not.toHaveBeenCalled()
},
)

View File

@ -1,20 +1,23 @@
import { commonContextMenuActions, type MenuItem } from '@/components/shared/AgGridTableView.vue'
import type { WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
import { requiredImportsByFQN, type RequiredImport } from '@/stores/graph/imports'
import type { SuggestionDb } from '@/stores/suggestionDatabase'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { tryEnsoToNumber, tryNumberToEnso } from '@/util/ast/abstract'
import { findIndexOpt } from '@/util/data/array'
import * as iterable from '@/util/data/iterable'
import { Err, Ok, transposeResult, unwrapOrWithLog, type Result } from '@/util/data/result'
import { qnLastSegment, type QualifiedName } from '@/util/qualifiedName'
import type { ToValue } from '@/util/reactivity'
import type { ColDef, MenuItemDef } from 'ag-grid-enterprise'
import type { ColDef } from 'ag-grid-enterprise'
import { computed, toValue } from 'vue'
const NEW_COLUMN_ID = 'NewColumn'
const ROW_INDEX_COLUMN_ID = 'RowIndex'
const NEW_COLUMN_HEADER = 'New Column'
const ROW_INDEX_HEADER = '#'
const DEFAULT_COLUMN_PREFIX = 'Column #'
const NOTHING_PATH = 'Standard.Base.Nothing.Nothing' as QualifiedName
const NOTHING_NAME = qnLastSegment(NOTHING_PATH)
@ -24,14 +27,6 @@ export type RowData = {
cells: Record<Ast.AstId, Ast.AstId>
}
/**
* A more specialized version of AGGrid's `MenuItemDef` to simplify testing (the tests need to provide
* only values actually used by the composable)
*/
export interface MenuItem extends MenuItemDef<RowData> {
action: (params: { node: { data: RowData | undefined } | null }) => void
}
/**
* A more specialized version of AGGrid's `ColDef` to simplify testing (the tests need to provide
* only values actually used by the composable)
@ -39,13 +34,13 @@ export interface MenuItem extends MenuItemDef<RowData> {
export interface ColumnDef extends ColDef<RowData> {
valueGetter: ({ data }: { data: RowData | undefined }) => any
valueSetter?: ({ data, newValue }: { data: RowData; newValue: any }) => boolean
mainMenuItems: (string | MenuItem)[]
contextMenuItems: (string | MenuItem)[]
mainMenuItems: (string | MenuItem<RowData>)[]
contextMenuItems: (string | MenuItem<RowData>)[]
rowDrag?: ({ data }: { data: RowData | undefined }) => boolean
}
namespace cellValueConversion {
/** TODO: Add docs */
/** Convert AST node to a value for Grid (to be returned from valueGetter, for example). */
export function astToAgGrid(ast: Ast.Ast) {
if (ast instanceof Ast.TextLiteral) return Ok(ast.rawTextContent)
else if (ast instanceof Ast.Ident && ast.code() === NOTHING_NAME) return Ok(null)
@ -57,7 +52,11 @@ namespace cellValueConversion {
}
}
/** TODO: Add docs */
/**
* Convert value of Grid cell (received, for example, from valueSetter) to AST module.
*
* Empty values are converted to `Nothing`, which may require appropriate import to work properly.
*/
export function agGridToAst(
value: unknown,
module: Ast.MutableModule,
@ -177,14 +176,13 @@ export function useTableNewArgument(
}
}
function addRow(edit: Ast.MutableModule, columnWithValue?: Ast.AstId, value?: unknown) {
for (const column of columns.value) {
function addRow(
edit: Ast.MutableModule,
valueGetter: (column: Ast.AstId, index: number) => unknown = () => null,
) {
for (const [index, column] of columns.value.entries()) {
const editedCol = edit.getVersion(column.data)
if (column.data.id === columnWithValue) {
editedCol.push(convertWithImport(value, edit))
} else {
editedCol.push(convertWithImport(null, edit))
}
editedCol.push(convertWithImport(valueGetter(column.data.id, index), edit))
}
}
@ -198,21 +196,21 @@ export function useTableNewArgument(
function addColumn(
edit: Ast.MutableModule,
name: string,
rowWithValue?: number,
value?: unknown,
valueGetter: (index: number) => unknown = () => null,
size: number = rowCount.value,
columns?: Ast.Vector,
) {
const newColumnSize = Math.max(rowCount.value, rowWithValue != null ? rowWithValue + 1 : 0)
function* cellsGenerator() {
for (let i = 0; i < newColumnSize; ++i) {
if (i === rowWithValue) yield convertWithImport(value, edit)
else yield convertWithImport(null, edit)
for (let i = 0; i < size; ++i) {
yield convertWithImport(valueGetter(i), edit)
}
}
const cells = Ast.Vector.new(edit, Array.from(cellsGenerator()))
const newCol = Ast.Vector.new(edit, [Ast.TextLiteral.new(name), cells])
const ast = unwrapOrWithLog(columnsAst.value, undefined, errorMessagePreamble)
const ast = columns ?? unwrapOrWithLog(columnsAst.value, undefined, errorMessagePreamble)
if (ast) {
edit.getVersion(ast).push(newCol)
return ast
} else {
const inputAst = edit.getVersion(toValue(input).value)
const newArg = Ast.Vector.new(edit, [newCol])
@ -221,6 +219,7 @@ export function useTableNewArgument(
} else {
inputAst.updateValue((func) => Ast.App.new(edit, func, undefined, newArg))
}
return newArg
}
}
@ -268,7 +267,12 @@ export function useTableNewArgument(
if (data.index === rowCount.value) {
addRow(edit)
}
addColumn(edit, NEW_COLUMN_HEADER, data.index, newValue)
addColumn(
edit,
`${DEFAULT_COLUMN_PREFIX}${columns.value.length}`,
(index) => (index === data.index ? newValue : null),
Math.max(rowCount.value, data.index + 1),
)
onUpdate({ edit })
return true
},
@ -282,7 +286,7 @@ export function useTableNewArgument(
virtualColumn: true,
},
mainMenuItems: ['autoSizeThis', 'autoSizeAll'],
contextMenuItems: ['paste', 'separator', removeRowMenuItem],
contextMenuItems: [commonContextMenuActions.paste, 'separator', removeRowMenuItem],
lockPosition: 'right',
}))
@ -295,7 +299,8 @@ export function useTableNewArgument(
contextMenuItems: [removeRowMenuItem],
cellStyle: { color: 'rgba(0, 0, 0, 0.4)' },
lockPosition: 'left',
rowDrag: ({ data }) => data?.index != null && data.index < rowCount.value,
rowDrag: ({ data }: { data: RowData | undefined }) =>
data?.index != null && data.index < rowCount.value,
}))
const columnDefs = computed(() => {
@ -323,7 +328,7 @@ export function useTableNewArgument(
const edit = graph.startEdit()
fixColumns(edit)
if (data.index === rowCount.value) {
addRow(edit, col.data.id, newValue)
addRow(edit, (colId) => (colId === col.data.id ? newValue : null))
} else {
const newValueAst = convertWithImport(newValue, edit)
if (astId != null) edit.replaceValue(astId, newValueAst)
@ -342,10 +347,10 @@ export function useTableNewArgument(
},
mainMenuItems: ['autoSizeThis', 'autoSizeAll', removeColumnMenuItem(col.id)],
contextMenuItems: [
'cut',
'copy',
commonContextMenuActions.cut,
commonContextMenuActions.copy,
'copyWithHeaders',
'paste',
commonContextMenuActions.paste,
'separator',
removeRowMenuItem,
removeColumnMenuItem(col.id),
@ -424,6 +429,57 @@ export function useTableNewArgument(
onUpdate({ edit })
}
function pasteFromClipboard(data: string[][], focusedCell: { rowIndex: number; colId: string }) {
if (data.length === 0) return
const edit = graph.startEdit()
const focusedColIndex =
findIndexOpt(columns.value, ({ id }) => id === focusedCell.colId) ?? columns.value.length
const newValueGetter = (rowIndex: number, colIndex: number) => {
if (rowIndex < focusedCell.rowIndex) return undefined
if (colIndex < focusedColIndex) return undefined
return data[rowIndex - focusedCell.rowIndex]?.[colIndex - focusedColIndex]
}
const pastedRowsEnd = focusedCell.rowIndex + data.length
const pastedColsEnd = focusedColIndex + data[0]!.length
// Set data in existing cells.
for (
let rowIndex = focusedCell.rowIndex;
rowIndex < Math.min(pastedRowsEnd, rowCount.value);
++rowIndex
) {
for (
let colIndex = focusedColIndex;
colIndex < Math.min(pastedColsEnd, columns.value.length);
++colIndex
) {
const column = columns.value[colIndex]!
const newValueAst = convertWithImport(newValueGetter(rowIndex, colIndex), edit)
edit.getVersion(column.data).set(rowIndex, newValueAst)
}
}
// Extend the table if necessary.
const newRowCount = Math.max(pastedRowsEnd, rowCount.value)
for (let i = rowCount.value; i < newRowCount; ++i) {
addRow(edit, (_colId, index) => newValueGetter(i, index))
}
const newColCount = Math.max(pastedColsEnd, columns.value.length)
let modifiedColumnsAst: Ast.Vector | undefined
for (let i = columns.value.length; i < newColCount; ++i) {
modifiedColumnsAst = addColumn(
edit,
`${DEFAULT_COLUMN_PREFIX}${i}`,
(index) => newValueGetter(index, i),
newRowCount,
modifiedColumnsAst,
)
}
onUpdate({ edit })
return
}
return {
/** All column definitions, to be passed to AgGrid component. */
columnDefs,
@ -445,5 +501,15 @@ export function useTableNewArgument(
* `overIndex` (the -1 case is handled)
*/
moveRow,
/**
* Paste data from clipboard to grid in AST. Do not change rowData, its updated upon
* expected WidgetInput change.
*
* This updates the data in a single update, so it replaces the standard AgGrid paste handlers.
* If the pasted data are to be placed outside current table, the table is extended.
* @param data the clipboard data, as retrieved in `processDataFromClipboard`.
* @param focusedCell the currently focused cell: will become the left-top cell of pasted data.
*/
pasteFromClipboard,
}
}

View File

@ -1,3 +1,42 @@
<script lang="ts">
import { gridBindings } from '@/bindings'
import type { MenuItemDef } from 'ag-grid-enterprise'
/**
* A more specialized version of AGGrid's `MenuItemDef` to simplify testing (the tests need to provide
* only values actually used by the composable)
*/
export interface MenuItem<TData> extends MenuItemDef<TData> {
action: (params: {
node: { data: TData | undefined } | null
api: { copyToClipboard: () => void; cutToClipboard: () => void; pasteFromClipboard: () => void }
}) => void
}
const AGGRID_DEFAULT_COPY_ICON =
'<span class="ag-icon ag-icon-copy" unselectable="on" role="presentation"></span>'
export const commonContextMenuActions = {
cut: {
name: 'Cut',
shortcut: gridBindings.bindings['cutCells'].humanReadable,
action: ({ api }) => api.cutToClipboard(),
icon: AGGRID_DEFAULT_COPY_ICON,
},
copy: {
name: 'Copy',
shortcut: gridBindings.bindings['copyCells'].humanReadable,
action: ({ api }) => api.copyToClipboard(),
icon: AGGRID_DEFAULT_COPY_ICON,
},
paste: {
name: 'Paste',
shortcut: gridBindings.bindings['pasteCells'].humanReadable,
action: ({ api }) => api.pasteFromClipboard(),
icon: AGGRID_DEFAULT_COPY_ICON,
},
} satisfies Record<string, MenuItem<unknown>>
</script>
<script setup lang="ts" generic="TData, TValue">
/**
* Component adding some useful logic to AGGrid table component (like keeping track of colum sizes),
@ -20,6 +59,7 @@ import type {
GetRowIdFunc,
GridApi,
GridReadyEvent,
ProcessDataFromClipboardParams,
RowDataUpdatedEvent,
RowEditingStartedEvent,
RowEditingStoppedEvent,
@ -41,6 +81,7 @@ const _props = defineProps<{
suppressDragLeaveHidesColumns?: boolean
suppressMoveWhenColumnDragging?: boolean
textFormatOption?: TextFormatOptions
processDataFromClipboard?: (params: ProcessDataFromClipboardParams<TData>) => string[][] | null
}>()
const emit = defineEmits<{
cellEditingStarted: [event: CellEditingStartedEvent]
@ -52,6 +93,7 @@ const emit = defineEmits<{
}>()
const widths = reactive(new Map<string, number>())
const wrapper = ref<HTMLElement>()
const grid = ref<ComponentInstance<typeof AgGridVue>>()
const gridApi = shallowRef<GridApi<TData>>()
const popupParent = document.body
@ -144,6 +186,37 @@ function sendToClipboard({ data }: { data: string }) {
defineExpose({ gridApi })
// === Keybinds ===
const handler = gridBindings.handler({
cutCells() {
if (gridApi.value?.getFocusedCell() == null) return false
gridApi.value?.cutToClipboard()
},
copyCells() {
if (gridApi.value?.getFocusedCell() == null) return false
gridApi.value?.copyToClipboard()
},
pasteCells() {
if (gridApi.value?.getFocusedCell() == null) return false
gridApi.value?.pasteFromClipboard()
},
})
function supressCopy(event: KeyboardEvent) {
// Suppress the default keybindings of AgGrid, because we want to use our own handlers (and bindings),
// and AgGrid API does not allow copy suppression.
if (
(event.code === 'KeyX' || event.code === 'KeyC' || event.code === 'KeyV') &&
event.ctrlKey &&
wrapper.value != null &&
event.target != wrapper.value
) {
event.stopPropagation()
wrapper.value.dispatchEvent(new KeyboardEvent(event.type, event))
}
}
// === Loading AGGrid and its license ===
const { LicenseManager } = await import('ag-grid-enterprise')
@ -176,40 +249,48 @@ const { AgGridVue } = await import('ag-grid-vue3')
</script>
<template>
<AgGridVue
v-bind="$attrs"
ref="grid"
class="ag-theme-alpine grid"
:headerHeight="26"
:getRowHeight="getRowHeight"
:rowData="rowData"
:columnDefs="columnDefs"
:defaultColDef="defaultColDef"
:sendToClipboard="sendToClipboard"
:suppressFieldDotNotation="true"
:enableRangeSelection="true"
:popupParent="popupParent"
:components="components"
:singleClickEdit="singleClickEdit"
:stopEditingWhenCellsLoseFocus="stopEditingWhenCellsLoseFocus"
:suppressDragLeaveHidesColumns="suppressDragLeaveHidesColumns"
:suppressMoveWhenColumnDragging="suppressMoveWhenColumnDragging"
@gridReady="onGridReady"
@firstDataRendered="updateColumnWidths"
@rowDataUpdated="updateColumnWidths($event), emit('rowDataUpdated', $event)"
@columnResized="lockColumnSize"
@cellEditingStarted="emit('cellEditingStarted', $event)"
@cellEditingStopped="emit('cellEditingStopped', $event)"
@rowEditingStarted="emit('rowEditingStarted', $event)"
@rowEditingStopped="emit('rowEditingStopped', $event)"
@sortChanged="emit('sortOrFilterUpdated', $event)"
@filterChanged="emit('sortOrFilterUpdated', $event)"
/>
<div ref="wrapper" @keydown="handler" @keydown.capture="supressCopy">
<AgGridVue
v-bind="$attrs"
ref="grid"
class="ag-theme-alpine grid"
:headerHeight="26"
:getRowHeight="getRowHeight"
:rowData="rowData"
:columnDefs="columnDefs"
:defaultColDef="defaultColDef"
:sendToClipboard="sendToClipboard"
:suppressFieldDotNotation="true"
:enableRangeSelection="true"
:popupParent="popupParent"
:components="components"
:singleClickEdit="singleClickEdit"
:stopEditingWhenCellsLoseFocus="stopEditingWhenCellsLoseFocus"
:suppressDragLeaveHidesColumns="suppressDragLeaveHidesColumns"
:suppressMoveWhenColumnDragging="suppressMoveWhenColumnDragging"
:processDataFromClipboard="processDataFromClipboard"
@gridReady="onGridReady"
@firstDataRendered="updateColumnWidths"
@rowDataUpdated="updateColumnWidths($event), emit('rowDataUpdated', $event)"
@columnResized="lockColumnSize"
@cellEditingStarted="emit('cellEditingStarted', $event)"
@cellEditingStopped="emit('cellEditingStopped', $event)"
@rowEditingStarted="emit('rowEditingStarted', $event)"
@rowEditingStopped="emit('rowEditingStopped', $event)"
@sortChanged="emit('sortOrFilterUpdated', $event)"
@filterChanged="emit('sortOrFilterUpdated', $event)"
/>
</div>
</template>
<style src="@ag-grid-community/styles/ag-grid.css" />
<style src="@ag-grid-community/styles/ag-theme-alpine.css" />
<style scoped>
.grid {
width: 100%;
height: 100%;
}
.ag-theme-alpine {
--ag-grid-size: 3px;
--ag-list-item-height: 20px;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import icons from '@/assets/icons.svg'
import AgGridTableView from '@/components/shared/AgGridTableView.vue'
import AgGridTableView, { commonContextMenuActions } from '@/components/shared/AgGridTableView.vue'
import { SortModel, useTableVizToolbar } from '@/components/visualizations/tableVizToolbar'
import { Ast } from '@/util/ast'
import { Pattern } from '@/util/ast/match'
@ -120,6 +120,7 @@ const defaultColDef: Ref<ColDef> = ref({
minWidth: 25,
cellRenderer: cellRenderer,
cellClass: cellClass,
contextMenuItems: [commonContextMenuActions.copy, 'copyWithHeaders', 'separator', 'export'],
} satisfies ColDef)
const rowData = ref<Record<string, any>[]>([])
const columnDefs: Ref<ColDef[]> = ref([])