Skip to content

Commit

Permalink
Table Editor Widget (#10774)
Browse files Browse the repository at this point in the history
Fixes #10293

The Table Editor Widget allows adding rows and columns, editing cells and renaming columns.

[Screencast from 2024-08-07 13-17-37.webm](https://github.com/user-attachments/assets/d2e708b5-6516-4107-bc17-f018e455c111)

# Important Notes
* The parts of Table Visualization which were useful for the widget were put in vue component. On this occasion, we use aggrid vue.
  • Loading branch information
farmaazon authored Aug 12, 2024
1 parent 1d0dfe1 commit 6eece5f
Show file tree
Hide file tree
Showing 16 changed files with 1,404 additions and 245 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Next Release

#### Enso IDE

- [Table Editor Widget][10774] displayed in `Table.new` component.

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

#### Enso Standard Library

- [Implemented in-memory and database mixed `Decimal` column
Expand Down
62 changes: 62 additions & 0 deletions app/gui2/e2e/widgets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,3 +533,65 @@ test('Autoscoped constructors', async ({ page }) => {
await expect(groupBy).toBeVisible()
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
})

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 widget = node.locator('.WidgetTableEditor')
await expect(widget).toBeVisible()
await expect(widget.locator('.ag-header-cell-text')).toHaveText('New Column')
await expect(widget.locator('.ag-header-cell-text')).toHaveClass(/(?<=^| )virtualColumn(?=$| )/)
// There's one empty cell, allowing creating first row and column
await expect(widget.locator('.ag-cell')).toHaveCount(1)

// Putting first value
await widget.locator('.ag-cell').dblclick()
await page.keyboard.type('Value')
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-cell')).toHaveText(['Value', '', '', ''])

// Renaming column
await widget.locator('.ag-header-cell-text').first().dblclick()
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').first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-cell').first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-header-cell-text').first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-header-cell-text').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)
})
2 changes: 2 additions & 0 deletions app/gui2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@vueuse/core": "^10.4.1",
"ag-grid-community": "^30.2.1",
"ag-grid-enterprise": "^30.2.1",
"ag-grid-vue3": "^30.2.1",
"codemirror": "^6.0.1",
"culori": "^3.2.0",
"enso-dashboard": "workspace:*",
Expand All @@ -82,6 +83,7 @@
"sucrase": "^3.34.0",
"veaury": "^2.3.18",
"vue": "^3.4.19",
"vue-component-type-helpers": "^2.0.29",
"y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5",
"y-textarea": "^1.0.0",
Expand Down
168 changes: 159 additions & 9 deletions app/gui2/src/components/GraphEditor/widgets/WidgetTableEditor.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,131 @@
<script setup lang="ts">
import { WidgetInputIsSpecificMethodCall } from '@/components/GraphEditor/widgets/WidgetFunction.vue'
import TableHeader from '@/components/GraphEditor/widgets/WidgetTableEditor/TableHeader.vue'
import {
tableNewCallMayBeHandled,
useTableNewArgument,
type RowData,
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
import ResizeHandles from '@/components/ResizeHandles.vue'
import AgGridTableView from '@/components/widgets/AgGridTableView.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore } from '@/stores/graph'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
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 } from 'ag-grid-community'
import type { Column } from 'ag-grid-enterprise'
import { computed, ref } from 'vue'
import { WidgetInputIsSpecificMethodCall } from './WidgetFunction.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
const size = ref(new Vec2(200, 50))
const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore()
const suggestionDb = useSuggestionDbStore()
const grid = ref<ComponentExposed<typeof AgGridTableView<RowData, any>>>()
const { rowData, columnDefs } = useTableNewArgument(
() => props.input,
graph,
suggestionDb.entries,
props.onUpdate,
)
// === Edit Handlers ===
class CellEditing {
handler: WidgetEditHandler
editedCell: { rowIndex: number; colKey: Column<RowData> } | undefined
supressNextStopEditEvent: boolean = false
constructor() {
this.handler = WidgetEditHandler.New('WidgetTableEditor.cellEditHandler', props.input, {
cancel() {
grid.value?.gridApi?.stopEditing(true)
},
end() {
grid.value?.gridApi?.stopEditing(false)
},
suspend: () => {
return {
resume: () => {
this.editedCell && grid.value?.gridApi?.startEditingCell(this.editedCell)
},
}
},
})
}
cellEditedInGrid(event: CellEditingStartedEvent) {
this.editedCell =
event.rowIndex != null ? { rowIndex: event.rowIndex, colKey: event.column } : undefined
if (!this.handler.isActive()) {
this.handler.start()
}
}
cellEditingStoppedInGrid(event: CellEditingStoppedEvent) {
if (!this.handler.isActive()) return
if (this.supressNextStopEditEvent && this.editedCell) {
this.supressNextStopEditEvent = false
// If row data changed, the editing will be stopped, but we want to continue it.
grid.value?.gridApi?.startEditingCell(this.editedCell)
} else {
this.handler.end()
}
}
rowDataChanged() {
if (this.handler.isActive()) {
this.supressNextStopEditEvent = true
}
}
}
const cellEditHandler = new CellEditing()
class HeaderEditing {
handler: WidgetEditHandler
stopEditingCallback: ((cancel: boolean) => void) | undefined
constructor() {
this.handler = WidgetEditHandler.New('WidgetTableEditor.headerEditHandler', props.input, {
cancel: () => {
this.stopEditingCallback?.(true)
},
end: () => {
this.stopEditingCallback?.(false)
},
})
}
headerEditedInGrid(stopCb: (cancel: boolean) => void) {
// If another header is edited, stop it (with the old callback).
if (this.handler.isActive()) {
this.stopEditingCallback?.(false)
}
this.stopEditingCallback = stopCb
if (!this.handler.isActive()) {
this.handler.start()
}
}
headerEditingStoppedInGrid() {
this.stopEditingCallback = undefined
if (this.handler.isActive()) {
this.handler.end()
}
}
}
const headerEditHandler = new HeaderEditing()
// === Resizing ===
const size = ref(new Vec2(200, 150))
const graphNav = injectGraphNavigator()
const clientBounds = computed({
Expand All @@ -26,7 +144,16 @@ const widgetStyle = computed(() => {
}
})
const _props = defineProps(widgetProps(widgetDefinition))
// === Column Default Definition ===
const defaultColDef = {
editable: true,
resizable: true,
headerComponentParams: {
onHeaderEditingStarted: headerEditHandler.headerEditedInGrid.bind(headerEditHandler),
onHeaderEditingStopped: headerEditHandler.headerEditingStoppedInGrid.bind(headerEditHandler),
},
}
</script>

<script lang="ts">
Expand All @@ -38,28 +165,51 @@ export const widgetDefinition = defineWidget(
}),
{
priority: 999,
// TODO[#10293]: This widget is not yet fully implemented, so it is temporarily disabled.
// Change this to `Score.Perfect` or implement appropriate score method as part of next task.
score: Score.Mismatch,
score: (props) => {
if (!tableNewCallMayBeHandled(props.input.value)) return Score.Mismatch
return Score.Perfect
},
},
import.meta.hot,
)
</script>

<template>
<div class="WidgetTableEditor" :style="widgetStyle">
<div>WidgetTableEditor</div>
<Suspense>
<AgGridTableView
ref="grid"
class="grid"
:defaultColDef="defaultColDef"
:columnDefs="columnDefs"
:rowData="rowData"
:getRowId="(row) => `${row.data.index}`"
:components="{ agColumnHeader: TableHeader }"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
@keydown.enter.stop
@cellEditingStarted="cellEditHandler.cellEditedInGrid($event)"
@cellEditingStopped="cellEditHandler.cellEditingStoppedInGrid($event)"
@rowDataUpdated="cellEditHandler.rowDataChanged()"
@pointerdown.stop
@click.stop
/>
</Suspense>
<ResizeHandles v-model="clientBounds" bottom right />
</div>
</template>

<style scoped>
.WidgetTableEditor {
color: yellow;
display: flex;
align-items: center;
justify-content: center;
background: #00ff0055;
border-radius: var(--node-port-border-radius);
position: relative;
}
.grid {
width: 100%;
height: 100%;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<script lang="ts">
import type { IHeaderParams } from 'ag-grid-community'
import { ref, watch } from 'vue'
/** Parameters recognized by this header component.
*
* They are set through `headerComponentParams` option in AGGrid column definition.
*/
export interface HeaderParams {
/** Setter called when column name is changed by the user. */
nameSetter?: (newName: string) => void
/** Column is virtual if it is not represented in the AST. Such column might be used
* to create new one.
*/
virtualColumn?: boolean
onHeaderEditingStarted?: (stop: (cancel: boolean) => void) => void
onHeaderEditingStopped?: () => void
}
</script>

<script setup lang="ts">
const props = defineProps<{
params: IHeaderParams & HeaderParams
}>()
const editing = ref(false)
const inputElement = ref<HTMLInputElement>()
watch(editing, (newVal, oldVal) => {
if (newVal) {
props.params.onHeaderEditingStarted?.((cancel: boolean) => {
if (cancel) editing.value = false
else acceptNewName()
})
} else {
props.params.onHeaderEditingStopped?.()
}
})
watch(inputElement, (newVal, oldVal) => {
if (newVal != null && oldVal == null) {
// Whenever input field appears, focus and select text
newVal.focus()
newVal.select()
}
})
function acceptNewName() {
if (inputElement.value == null) {
console.error('Tried to accept header new name without input element!')
return
}
props.params.nameSetter?.(inputElement.value.value)
editing.value = false
}
</script>

<template>
<div class="ag-cell-label-container" role="presentation" @pointerdown.stop @click.stop>
<div class="ag-header-cell-label" role="presentation">
<input
v-if="editing"
ref="inputElement"
class="ag-input-field-input ag-text-field-input"
:value="params.displayName"
@change="acceptNewName()"
@keydown.arrow-left.stop
@keydown.arrow-right.stop
@keydown.arrow-up.stop
@keydown.arrow-down.stop
/>
<span
v-else
class="ag-header-cell-text"
:class="{ virtualColumn: params.virtualColumn === true }"
@click="editing = params.nameSetter != null"
>{{ params.displayName }}</span
>
</div>
</div>
</template>

<style>
.virtualColumn {
color: rgba(0, 0, 0, 0.5);
}
</style>
Loading

0 comments on commit 6eece5f

Please sign in to comment.