mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 22:21:40 +03:00
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:
parent
1fbaeaa767
commit
fa87a1857a
@ -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
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
])
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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'],
|
||||
})
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
},
|
||||
)
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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([])
|
||||
|
Loading…
Reference in New Issue
Block a user