Multi Row Edit with Vuetify Data Table

For the power end user, an editable data grid is frequently the most productive user interface for quickly editing data. I have been working with Vue 3 for some time now and the Vuetify component library has made it quite simple for me to quickly create interesting user interfaces (at least for the type of prototype or self-use applications I frequently work on).

Vuetify has a nice data table component, but it is read-only. It supports various slots that allow us to extend it and make it support edit operations as well. And with some additional tweaks, we can optimize quick navigation beween rows. I have created a simple component that may provide you (and me in the future) with some useful inspiration. You can find the component demonstrated — along with source code — on the Vuetify Playground.

Here is the end result in a little video:

Multi Row Edit with Vuetify Data Table 1*4TwdKB6t01GsMY0qJNgGmA

The multi row data editor. Including the ability to use Up and Down arrow keys to navigate between rows and auto selection of the field value for quick editing. Sort and filter is standard v-data-table functionality.

What are the key features?

  • present editable fields for every property that should be editable
  • allow up and down navigation using arrow keys
  • when a field gets focus as a result of up or down navigation, the value in the field is immediately selected so it can easily be edited
  • when the Save Changes button is pressed, an array with only the changed records is emitted

Not a functional feature but useful all the same: the component demonstrates how dynamically generated components in a v-data-table can be associated through their ref attribute to a programmatic object and in turn can be manipulated from code (such as navigated to and value selected after focus).

The component that uses the MultiRowEdit component defines the data set to be presented and handles the dataChanged event. Note: ideally the component would introspect the data set passed into it and dynamically determine columns and input fields.

<template>
<v-app>
<v-container>
<MultiRowEdit
:data="events"
title="Edit Olympic Sports"
@dataChanged="handleDataChanged"
/>
</v-container>
</v-app>
</template>

<script setup>
import { ref } from 'vue'
import MultiRowEdit from './MultiRowEdit.vue'

const handleDataChanged = data => {
console.log('Olympic Sports Data has changed ', data)
}
const events = [
{
name: '100m',
sport: 'Sprint',
category: 'Individual',
venue: 'Outdoors',
},
{
name: 'Long Jump',
sport: 'Athletics',
category: 'Individual',
venue: 'Outdoors',
},...
]
</script>

The MultiRowEditComponent itself defines properties title and data and the emitted event dataChanged.

<template>
<v-card flat>
<v-divider></v-divider>
<v-data-table
:headers="headers"
:items="localData"
item-value="id"
item-key="id"
:search="search"
class="elevation-1 dense-table row-height-50"
show-select
dense
>
<template v-slot:top>
<v-toolbar flat>
<v-toolbar-title>{{ props.title }}</v-toolbar-title>
<v-divider class="mx-4" inset vertical></v-divider>
<v-text-field
v-model="search"
density="compact"
label="Search"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
flat
hide-details
single-line
></v-text-field>
<v-spacer></v-spacer>
<v-btn color="primary" @click="saveChanges">Save Changes</v-btn>
</v-toolbar>
</template>

<template v-slot:item.name="{ item, index }">
<v-text-field
v-model="item.name"
label="Name"
dense
@change="update(item)"
@keydown.down="focusNextRow(index)"
@keydown.up="focusPreviousRow(index)"
:ref="(el) => (fields['field-' + index] = el)"
>
</v-text-field>
</template>
<template v-slot:item.venue="{ item }">
<v-text-field
v-model="item.venue"
label="Indoor or Outside"
@change="update(item)"
></v-text-field>
</template>
</v-data-table>
</v-card>
</template>

<script setup>
import { onMounted, ref, nextTick } from 'vue'

const search = ref('')
const fields = ref({})

const emits = defineEmits(['dataChanged'])

const props = defineProps({
data: {
type: Array,
required: true,
},
title: {
type: String,
required: true,
},
})

const localData = ref([])

const headers = ref([
{ title: 'Name', value: 'name', sortable: true, width: '180px' },
{ title: 'Sport', value: 'sport', sortable: true, width: '100px' },
{ title: 'Category', value: 'category', sortable: true },
{ title: 'Venue', value: 'venue', sortable: true },
])

const saveChanges = () => {
emits(
'dataChanged',
localData.value
.filter(item => item.updated)
.map(item => {
delete item.updated
return item
})
)
}

const update = item => {
item.updated = true
}
onMounted(() => {
localData.value = [...props.data, { updated: false }]
})

const focusNextRow = index => {
const nextIndex = index + 1
const nextField = fields.value['field-' + nextIndex]
if (nextField) {
nextField.focus() // Focus the next field
selectInputValue(nextIndex)
}
}

const focusPreviousRow = index => {
const nextIndex = index - 1
const nextField = fields.value['field-' + nextIndex]
if (nextField) {
nextField.focus() // Focus the next field
selectInputValue(nextIndex)
}
}

const selectInputValue = index => {
const field = fields.value['field-' + index]
if (field && field.$el.querySelector('input')) {
setTimeout(() => {
field.$el.querySelector('input').select() // Select the value in the input field
}, 100)
}
}
</script>

The most interesting bit is:

<v-text-field
v-model="item.name"
label="Name"
dense
@change="update(item)"
@keydown.down="focusNextRow(index)"
@keydown.up="focusPreviousRow(index)"
:ref="(el) => (fields['field-' + index] = el)"
>
</v-text-field>

The ref attribute is used to pass the reference to the HTML component as a property in the fields object. The name of the property is defined as field-<row index>. In any section of code I can get hold of the component from the fields object provided I know the index.

In focusNextRow — invoked from the @keydown.down event listener — I determine the property value to use for retrieving the component one row below the one on which the Down Arrow key was pressed. I then focus that component. And next I make the contents of the input component selected by calling selectInputValue to do it for me.

const focusNextRow = index => {
const nextIndex = index + 1
const nextField = fields.value['field-' + nextIndex]
if (nextField) {
nextField.focus() // Focus the next field
selectInputValue(nextIndex)
}
}

The code in selectInputValue looks a little bit more complex than you would expect. I was struggling with timing: selecting the value immediately after putting focus on the component did not seem to work. Hence the setTimeout to move the selection a little bit back in time.

  const selectInputValue = index => {
const field = fields.value['field-' + index]
if (field && field.$el.querySelector('input')) {
setTimeout(() => {
field.$el.querySelector('input').select() // Select the value in the input field
}, 100)
}
}

Now it works.

I hope this is useful to you.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.