Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Table widget: reordering rows and columns #11271

Merged
merged 12 commits into from
Oct 17, 2024
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

- [Rows and Columns may be now removed in Table Input Widget][11151]. The option
is available in right-click context menu.
- [Rows and Columns may be now reordered by dragging in Table Input
Widget][11271]

[11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271

#### Enso Standard Library

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type { CellEditingStartedEvent, CellEditingStoppedEvent, Column } from 'ag-grid-enterprise'
import type {
CellEditingStartedEvent,
CellEditingStoppedEvent,
Column,
ColumnMovedEvent,
RowDragEndEvent,
} from 'ag-grid-enterprise'
import { computed, ref } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'

Expand All @@ -26,7 +32,7 @@ const graph = useGraphStore()
const suggestionDb = useSuggestionDbStore()
const grid = ref<ComponentExposed<typeof AgGridTableView<RowData, any>>>()

const { rowData, columnDefs } = useTableNewArgument(
const { rowData, columnDefs, moveColumn, moveRow } = useTableNewArgument(
() => props.input,
graph,
suggestionDb.entries,
Expand Down Expand Up @@ -141,11 +147,27 @@ const widgetStyle = computed(() => {
}
})

// === Column and Row Dragging ===

function onColumnMoved(event: ColumnMovedEvent<RowData>) {
if (event.column && event.toIndex != null && event.finished) {
moveColumn(event.column.getColId(), event.toIndex)
}
}

function onRowDragEnd(event: RowDragEndEvent<RowData>) {
if (event.node.data != null) {
moveRow(event.node.data?.index, event.overIndex)
}
}

// === Column Default Definition ===

const defaultColDef = {
editable: true,
resizable: true,
sortable: false,
lockPinned: true,
headerComponentParams: {
onHeaderEditingStarted: headerEditHandler.headerEditedInGrid.bind(headerEditHandler),
onHeaderEditingStopped: headerEditHandler.headerEditingStoppedInGrid.bind(headerEditHandler),
Expand Down Expand Up @@ -184,6 +206,8 @@ export const widgetDefinition = defineWidget(
:components="{ agColumnHeader: TableHeader }"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
:suppressDragLeaveHidesColumns="true"
:suppressMoveWhenColumnDragging="true"
@keydown.enter.stop
@keydown.arrow-left.stop
@keydown.arrow-right.stop
Expand All @@ -196,6 +220,8 @@ export const widgetDefinition = defineWidget(
@rowDataUpdated="cellEditHandler.rowDataChanged()"
@pointerdown.stop
@click.stop
@columnMoved="onColumnMoved"
@rowDragEnd="onRowDragEnd"
/>
</Suspense>
<ResizeHandles v-model="clientBounds" bottom right />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,35 @@ test.each([
expect(tableNewCallMayBeHandled(ast)).toBeFalsy()
})

function tableEditFixture(code: string, expectedCode: string) {
const ast = Ast.parseBlock(code)
const inputAst = [...ast.statements()][0]
assert(inputAst != null)
const input = WidgetInput.FromAst(inputAst)
const startEdit = vi.fn(() => ast.module.edit())
const onUpdate = vi.fn((update) => {
const inputAst = [...update.edit.getVersion(ast).statements()][0]
expect(inputAst?.code()).toBe(expectedCode)
})
const addMissingImports = vi.fn((_, imports) => {
// the only import we're going to add is Nothing.
expect(imports).toEqual([
{
kind: 'Unqualified',
from: 'Standard.Base.Nothing',
import: 'Nothing',
},
])
})
const tableNewArgs = useTableNewArgument(
input,
{ startEdit, addMissingImports },
suggestionDbWithNothing(),
onUpdate,
)
return { tableNewArgs, startEdit, onUpdate, addMissingImports }
}

test.each([
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
Expand Down Expand Up @@ -211,29 +240,7 @@ test.each([
expected: "Table.new [['a', [1, 5]], ['a', [3, 4]]]",
},
])('Editing table $code: $description', ({ code, edit, expected, importExpected }) => {
const ast = Ast.parseBlock(code)
const inputAst = [...ast.statements()][0]
assert(inputAst != null)
const input = WidgetInput.FromAst(inputAst)
const onUpdate = vi.fn((update) => {
const inputAst = [...update.edit.getVersion(ast).statements()][0]
expect(inputAst?.code()).toBe(expected)
})
const addMissingImports = vi.fn((_, imports) => {
expect(imports).toEqual([
{
kind: 'Unqualified',
from: 'Standard.Base.Nothing',
import: 'Nothing',
},
])
})
const tableNewArgs = useTableNewArgument(
input,
{ startEdit: () => ast.module.edit(), addMissingImports },
suggestionDbWithNothing(),
onUpdate,
)
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
const editedRow = tableNewArgs.rowData.value[edit.row]
assert(editedRow != null)
tableNewArgs.columnDefs.value[edit.column]?.valueSetter?.({
Expand Down Expand Up @@ -262,7 +269,7 @@ test.each([
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
removedRowIndex: 0,
expected: "Table.new [['a', [ 2, 3]], ['b', [ 5, 6]]]",
expected: "Table.new [['a', [2, 3]], ['b', [5, 6]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
Expand All @@ -280,21 +287,8 @@ test.each([
expected: "Table.new [['a', []], ['b', []]]",
},
])('Removing $removedRowIndex row in $code', ({ code, removedRowIndex, expected }) => {
const ast = Ast.parseBlock(code)
const inputAst = [...ast.statements()][0]
assert(inputAst != null)
const addMissingImports = vi.fn()
const onUpdate = vi.fn((update) => {
const inputAst = [...update.edit.getVersion(ast).statements()][0]
expect(inputAst?.code()).toBe(expected)
})
const input = WidgetInput.FromAst(inputAst)
const tableNewArgs = useTableNewArgument(
input,
{ startEdit: () => ast.module.edit(), addMissingImports },
new SuggestionDb(),
onUpdate,
)
const { tableNewArgs, onUpdate, addMissingImports } = 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).
Expand All @@ -312,7 +306,7 @@ test.each([
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]], ['c', [5, 6]]]",
removedColIndex: 1,
expected: "Table.new [ ['b', [3, 4]], ['c', [5, 6]]]",
expected: "Table.new [['b', [3, 4]], ['c', [5, 6]]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]], ['c', [5, 6]]]",
Expand All @@ -330,21 +324,7 @@ test.each([
expected: 'Table.new []',
},
])('Removing $removedColIndex column in $code', ({ code, removedColIndex, expected }) => {
const ast = Ast.parseBlock(code)
const inputAst = [...ast.statements()][0]
assert(inputAst != null)
const addMissingImports = vi.fn()
const onUpdate = vi.fn((update) => {
const inputAst = [...update.edit.getVersion(ast).statements()][0]
expect(inputAst?.code()).toBe(expected)
})
const input = WidgetInput.FromAst(inputAst)
const tableNewArgs = useTableNewArgument(
input,
{ startEdit: () => ast.module.edit(), addMissingImports },
new SuggestionDb(),
onUpdate,
)
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
const removedCol = tableNewArgs.columnDefs.value[removedColIndex]
assert(removedCol != null)
const removeAction = getCustomMenuItemByName('Remove Column', removedCol.mainMenuItems)
Expand All @@ -359,3 +339,56 @@ test.each([

expect(addMissingImports).not.toHaveBeenCalled()
})

test.each([
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]], ['c', [5, 6]]]",
fromIndex: 1,
toIndex: 3,
expected: "Table.new [['b', [3, 4]], ['c', [5, 6]], ['a', [1, 2]]]",
},
{
code: "Table.new [['a', [1, 2]], ['b', [3, 4]], ['c', [5, 6]]]",
fromIndex: 3,
toIndex: 2,
expected: "Table.new [['a', [1, 2]], ['c', [5, 6]], ['b', [3, 4]]]",
},
])(
'Moving column $fromIndex to $toIndex in table $code',
({ code, fromIndex, toIndex, expected }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
const movedColumnDef = tableNewArgs.columnDefs.value[fromIndex]
assert(movedColumnDef?.colId != null)
tableNewArgs.moveColumn(movedColumnDef.colId, toIndex)
expect(onUpdate).toHaveBeenCalledOnce()
expect(addMissingImports).not.toHaveBeenCalled()
},
)

test.each([
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
fromIndex: 1,
toIndex: 2,
expected: "Table.new [['a', [1, 3, 2]], ['b', [4, 6, 5]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
fromIndex: 2,
toIndex: 0,
expected: "Table.new [['a', [3, 1, 2]], ['b', [6, 4, 5]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
fromIndex: 1,
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 }) => {
const { tableNewArgs, onUpdate, addMissingImports } = tableEditFixture(code, expected)
tableNewArgs.moveRow(fromIndex, toIndex)
if (code !== expected) {
expect(onUpdate).toHaveBeenCalledOnce()
}
expect(addMissingImports).not.toHaveBeenCalled()
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SuggestionDb } from '@/stores/suggestionDatabase'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { tryEnsoToNumber, tryNumberToEnso } from '@/util/ast/abstract'
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'
Expand Down Expand Up @@ -40,6 +41,7 @@ export interface ColumnDef extends ColDef<RowData> {
valueSetter?: ({ data, newValue }: { data: RowData; newValue: any }) => boolean
mainMenuItems: (string | MenuItem)[]
contextMenuItems: (string | MenuItem)[]
rowDrag?: ({ data }: { data: RowData | undefined }) => boolean
}

namespace cellValueConversion {
Expand Down Expand Up @@ -281,6 +283,7 @@ export function useTableNewArgument(
},
mainMenuItems: ['autoSizeThis', 'autoSizeAll'],
contextMenuItems: ['paste', 'separator', removeRowMenuItem],
lockPosition: 'right',
}))

const rowIndexColumnDef = computed<ColumnDef>(() => ({
Expand All @@ -291,6 +294,8 @@ export function useTableNewArgument(
mainMenuItems: ['autoSizeThis', 'autoSizeAll'],
contextMenuItems: [removeRowMenuItem],
cellStyle: { color: 'rgba(0, 0, 0, 0.4)' },
lockPosition: 'left',
rowDrag: ({ data }) => data?.index != null && data.index < rowCount.value,
}))

const columnDefs = computed(() => {
Expand Down Expand Up @@ -382,5 +387,62 @@ export function useTableNewArgument(
return ast
}

return { columnDefs, rowData }
function moveColumn(colId: string, toIndex: number) {
if (!columnsAst.value.ok) {
columnsAst.value.error.log('Cannot reorder columns: The table AST is not available')
return
}
if (!columnsAst.value.value) {
console.error('Cannot reorder columns on placeholders! This should not be possible in the UI')
return
}
const edit = graph.startEdit()
const columns = edit.getVersion(columnsAst.value.value)
const fromIndex = iterable.find(columns.enumerate(), ([, ast]) => ast?.id === colId)?.[0]
if (fromIndex != null) {
columns.move(fromIndex, toIndex - 1)
onUpdate({ edit })
}
}

function moveRow(rowIndex: number, overIndex: number) {
if (!columnsAst.value.ok) {
columnsAst.value.error.log('Cannot reorder rows: The table AST is not available')
return
}
if (!columnsAst.value.value) {
console.error('Cannot reorder rows on placeholders! This should not be possible in the UI')
return
}
// If dragged out of grid, we do nothing.
if (overIndex === -1) return
const edit = graph.startEdit()
for (const col of columns.value) {
const editedCol = edit.getVersion(col.data)
editedCol.move(rowIndex, overIndex)
}
onUpdate({ edit })
}

return {
/** All column definitions, to be passed to AgGrid component. */
columnDefs,
/**
* Row Data, to be passed to AgGrid component. They do not contain values, but AstIds.
* The column definitions have proper getters for obtaining value from AST.
*/
rowData,
/** Move column in AST. Do not change colunDefs, they are updated upon expected widgetInput change.
* @param colId the id of moved column (as got from `getColId()`)
* @param toIndex the new index of column as in view (counting in the row index column).
*/
moveColumn,
/**
* Move row in AST. Do not change rowData, its updated upon expected widgetInput change.
* @param rowIndex the index of moved row.
* @param overIndex the index of row over which this row was dropped, as in RowDragEndEvent's
* `overIndex` (the -1 case is handled)
*/
moveRow,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const _props = defineProps<{
components?: Record<string, unknown>
singleClickEdit?: boolean
stopEditingWhenCellsLoseFocus?: boolean
suppressDragLeaveHidesColumns?: boolean
suppressMoveWhenColumnDragging?: boolean
textFormatOption?: TextFormatOptions
}>()
const emit = defineEmits<{
Expand Down Expand Up @@ -190,6 +192,8 @@ const { AgGridVue } = await import('ag-grid-vue3')
:components="components"
:singleClickEdit="singleClickEdit"
:stopEditingWhenCellsLoseFocus="stopEditingWhenCellsLoseFocus"
:suppressDragLeaveHidesColumns="suppressDragLeaveHidesColumns"
:suppressMoveWhenColumnDragging="suppressMoveWhenColumnDragging"
@gridReady="onGridReady"
@firstDataRendered="updateColumnWidths"
@rowDataUpdated="updateColumnWidths($event), emit('rowDataUpdated', $event)"
Expand Down
Loading
Loading