In the previous part, we did a deep dive into editing a table, both at a row and cell level. The approach we did can only support one data schema as the template slots are explicitly using the field keys.
In this tutorial, we’ll go through the steps required to make the table dynamic by decoupling the code and reuse it across the whole project with different schemas.
Before we start, below is the full code from the previous part (editing table rows):
<template>
<article>
<b-table :items="items" :fields="fields">
<template #cell(name)="data">
<b-form-input v-if="items[data.index].isEdit" type="text" v-model="items[data.index].name"></b-form-input>
<span v-else>{{data.value}}</span>
</template>
<template #cell(department)="data">
<b-form-select v-if="items[data.index].isEdit" v-model="items[data.index].department" :options="['Development', 'Marketing', 'HR', 'Accounting']"></b-form-select>
<span v-else>{{data.value}}</span>
</template>
<template #cell(age)="data">
<b-form-input v-if="items[data.index].isEdit" type="number" v-model="items[data.index].age"></b-form-input>
<span v-else>{{data.value}}</span>
</template>
<template #cell(dateOfBirth)="data">
<b-form-datepicker v-if="items[data.index].isEdit" v-model="items[data.index].dateOfBirth"></b-form-datepicker>
<span v-else>{{data.value}}</span>
</template>
<template #cell(edit)="data">
<b-button @click="editRowHandler(data)">
<span v-if="!items[data.index].isEdit">Edit</span>
<span v-else>Done</span>
</b-button>
</template>
</b-table>
<pre>
{{items}}
</pre>
</article>
</template>
<script>
export default {
name: "App",
components: {},
data() {
return {
fields: [
{ key: "name", label: "Name"},
{ key: "department", label: "Department" },
{ key: "age", label: "Age" },
{ key: "dateOfBirth", label: "Date Of Birth" },
{ key: 'edit', label: ''}
],
items: [
{ age: 40, name: 'Dickerson', department: 'Development', dateOfBirth: '1984-05-20' },
{ age: 21, name: 'Larsen', department: 'Marketing', dateOfBirth: '1984-05-20' },
{ age: 89, name: 'Geneva', department: 'HR', dateOfBirth: '1984-05-20' },
{ age: 38, name: 'Jami', department: 'Accounting', dateOfBirth: '1984-05-20' }
],
selectedRow: {}
};
},
mounted() {
this.items = this.items.map(item => ({...item, isEdit: false}));
},
methods: {
editRowHandler(data) {
this.items[data.index].isEdit = !this.items[data.index].isEdit;
}
}
};
</script>
<style>
</style>
As we continue building upon the same code from previous parts in this series, it’s recommended that you go through these tutorials from the beginning to understand how the code structure works as we will not be explaining it. You can navigate to any part using the table of contents.
Code encapsulation
Encapsulating the code means creating an independent component with isolated features that can be consumed anywhere in the project. In our case, the editable table code was all done in app.vue
so we’ll extract the code to a separate component with the following steps:
- Create a new component
EditableTable.vue
- Add two props in the EditableTable component (
items
andfields
) - Move the existing code but without the data part (
items
andfields
) as this now is required to be passed as a prop by the consumer - In
app.vue
, import the new component and pass it the required props.
EditableTable Component
<template>
<b-table :items="tableItems" :fields="fields">
<template #cell(name)="data">
<b-form-input v-if="tableItems[data.index].isEdit" type="text" v-model="tableItems[data.index].name"></b-form-input>
<span v-else>{{data.value}}</span>
</template>
<template #cell(department)="data">
<b-form-select v-if="tableItems[data.index].isEdit" v-model="tableItems[data.index].department" :options="['Development', 'Marketing', 'HR', 'Accounting']"></b-form-select>
<span v-else>{{data.value}}</span>
</template>
<template #cell(age)="data">
<b-form-input v-if="tableItems[data.index].isEdit" type="number" v-model="tableItems[data.index].age"></b-form-input>
<span v-else>{{data.value}}</span>
</template>
<template #cell(dateOfBirth)="data">
<b-form-datepicker v-if="tableItems[data.index].isEdit" v-model="tableItems[data.index].dateOfBirth"></b-form-datepicker>
<span v-else>{{data.value}}</span>
</template>
<template #cell(edit)="data">
<b-button @click="editRowHandler(data)">
<span v-if="!tableItems[data.index].isEdit">Edit</span>
<span v-else>Done</span>
</b-button>
</template>
</b-table>
</template>
<script>
export default {
name: "EditableTable",
components: {},
props: {
items: Array,
fields: Array
},
data() {
return {
tableItems: this.items.map(item => ({...item, isEdit: false}))
}
},
methods: {
editRowHandler(data) {
this.tableItems[data.index].isEdit = !this.tableItems[data.index].isEdit;
}
}
};
</script>
<style>
</style>
Notice we are not using items
anymore, instead, we created a new data called tableItems
. If you recall from the previous part, we had to re-generate the items
model to include a new property isEdit
for every row. This will determine whether or not a row is in edit mode. In the above code, we are doing the same thing except we created a new data array as it’s considered a bad practice to modify the prop directly. Props should only be modified by the consumer.
Main App Component
<template>
<div id="app">
<EditableTable :items="items" :fields="fields"></EditableTable>
</div>
</template>
<script>
import EditableTable from './components/EditableTable.vue'
export default {
name: "App",
components: {
EditableTable
},
data() {
return {
fields: [
{ key: "name", label: "Name" },
{ key: "department", label: "Department" },
{ key: "age", label: "Age" },
{ key: "dateOfBirth", label: "Date Of Birth" },
{ key: "edit", label: "" }
],
items: [
{ age: 40, name: 'Dickerson', department: 'Development', dateOfBirth: '1984-05-20' },
{ age: 21, name: 'Larsen', department: 'Marketing', dateOfBirth: '1984-05-20' },
{ age: 89, name: 'Geneva', department: 'HR', dateOfBirth: '1984-05-20' },
{ age: 38, name: 'Jami', department: 'Accounting', dateOfBirth: '1984-05-20' }
]
};
}
};
</script>
<style>
#app {
text-align: center;
margin: 60px;
}
thead, tbody, tfoot, tr, td, th {
text-align: left;
width: 100px;
vertical-align: middle;
}
pre {
text-align: left;
color: #d63384 !important;
}
</style>
Dynamic schema
Encapsulation was a good step forward towards sharing the code and reducing redundancies. But with great power comes great responsibility! What if we need different columns for different datasets? What’s the point of creating a reusable table component if we can only have one schema? We have to keep the consumer happy and make sure the component is flexible enough to meet different scenarios.
Implementing a dynamic schema is a bit tricky but it will end up with less code and a more robust and scalable component. Here are the steps required to achieve this objective:
- Add a new property to the
fields
object to determine the type of the input field. For example, the “Name” field will be{ key: "name", label: "Name", type: "text"}
and the “Date of Birth” will be{ key: "dateOfBirth", label: "Date Of Birth", type: "date" }
- Instead of creating multiple slots, we only need one with a
for
loop and dynamic slots. Vue allows dynamic slots by using the square bracket as well as string interpolation to attach the key of the current field. E.g.#[`cell(${field.key})`]
- Within the slot, we can add a condition to check the type and show the proper input field element.
- Finally, we need a way to pass the options to the select element. One way to make this work is by adding another property to the field object with the options we need. E.g.
options: ['option1', 'option2', 'option3'...]
EditableTable component
<template>
<b-table :items="tableItems" :fields="fields">
<template v-for="(field, index) in fields" #[`cell(${field.key})`]="data">
<b-form-datepicker v-if="field.type === 'date' && tableItems[data.index].isEdit" :key="index" :type="field.type" v-model="tableItems[data.index][field.key]"></b-form-datepicker>
<b-form-select v-else-if="field.type === 'select' && tableItems[data.index].isEdit" :key="index" v-model="tableItems[data.index][field.key]" :options="field.options"></b-form-select>
<b-button :key="index" v-else-if="field.type === 'edit'" @click="editRowHandler(data)">
<span v-if="!tableItems[data.index].isEdit">Edit</span>
<span v-else>Done</span>
</b-button>
<b-form-input v-else-if="field.type && tableItems[data.index].isEdit" :key="index" :type="field.type" v-model="tableItems[data.index][field.key]"></b-form-input>
<span :key="index" v-else>{{data.value}}</span>
</template>
</b-table>
</template>
<script>
export default {
name: "EditableTable",
components: {},
props: {
items: Array,
fields: Array
},
data() {
return {
tableItems: this.items.map(item => ({...item, isEdit: false}))
}
},
methods: {
editRowHandler(data) {
this.tableItems[data.index].isEdit = !this.tableItems[data.index].isEdit;
}
}
};
</script>
<style>
</style>
Main App component
<template>
<div id="app">
<EditableTable :items="items" :fields="fields"></EditableTable>
</div>
</template>
<script>
import EditableTable from './components/EditableTable.vue'
export default {
name: "App",
components: {
EditableTable
},
data() {
return {
fields: [
{ key: "name", label: "Name", type: 'text' },
{ key: "department", label: "Department", type: 'select', options: ['Development', 'Marketing', 'HR', 'Accounting'] },
{ key: "age", label: "Age", type: 'number' },
{ key: "dateOfBirth", label: "Date Of Birth", type: 'date' },
{ key: "edit", label: "", type: "edit" }
],
items: [
{ age: 40, name: 'Dickerson', department: 'Development', dateOfBirth: '1984-05-20' },
{ age: 21, name: 'Larsen', department: 'Marketing', dateOfBirth: '1984-05-20' },
{ age: 89, name: 'Geneva', department: 'HR', dateOfBirth: '1984-05-20' },
{ age: 38, name: 'Jami', department: 'Accounting', dateOfBirth: '1984-05-20' }
]
};
}
};
</script>
<style>
#app {
text-align: center;
margin: 60px;
}
thead, tbody, tfoot, tr, td, th {
text-align: left;
width: 100px;
vertical-align: middle;
}
pre {
text-align: left;
color: #d63384 !important;
}
</style>
Two-way binding
Since we decoupled the table code and are using a new data array (tableItems
), the consumer can’t see the updated values anymore. Luckily, Vue provides us with the option to use a v-model
in any custom component even if it’s not solely an input element.
To enable v-model
in the custom table component, Vue requires us to provide a value
as a prop and emit an input
event with the updated value. In our case, we’ll replace items
prop with value
:
props: {
value: Array,
fields: Array
}
To update the value, we’ll create an inputHandler
function that will update tableItems
and emit the latest changes:
inputHandler(value, index, key) {
this.tableItems[index][key] = value;
this.$set(this.tableItems, index, this.tableItems[index]);
this.$emit("input", this.tableItems);
}
We don’t need the v-model
in the input element anymore, we’ll add the following code instead:
:value="tableItems[data.index][field.key]" @blur="(e) => inputHandler(e.target.value, data.index, field.key)"
Vue 3.x introduced a breaking change when using v-model with custom components. Please refer to the official Vue documentation to learn more.
Lastly, in the main App component, :items="items"
will become v-model="items"
EditableTable component
<template>
<b-table :items="tableItems" :fields="fields">
<template v-for="(field, index) in fields" #[`cell(${field.key})`]="data">
<b-form-datepicker v-if="field.type === 'date' && tableItems[data.index].isEdit" :key="index" :type="field.type" :value="tableItems[data.index][field.key]" @input="(value) => inputHandler(value, data.index, field.key)"></b-form-datepicker>
<b-form-select v-else-if="field.type === 'select' && tableItems[data.index].isEdit" :key="index" :value="tableItems[data.index][field.key]" @input="(value) => inputHandler(value, data.index, field.key)" :options="field.options"></b-form-select>
<b-button :key="index" v-else-if="field.type === 'edit'" @click="editRowHandler(data)">
<span v-if="!tableItems[data.index].isEdit">Edit</span>
<span v-else>Done</span>
</b-button>
<b-form-input v-else-if="field.type && tableItems[data.index].isEdit" :key="index" :type="field.type" :value="tableItems[data.index][field.key]" @blur="(e) => inputHandler(e.target.value, data.index, field.key)"></b-form-input>
<span :key="index" v-else>{{data.value}}</span>
</template>
</b-table>
</template>
<script>
export default {
name: "EditableTable",
components: {},
props: {
value: Array,
fields: Array
},
data() {
return {
tableItems: this.value.map(item => ({...item, isEdit: false}))
}
},
methods: {
editRowHandler(data) {
this.tableItems[data.index].isEdit = !this.tableItems[data.index].isEdit;
},
inputHandler(value, index, key) {
this.tableItems[index][key] = value;
this.$set(this.tableItems, index, this.tableItems[index]);
this.$emit("input", this.tableItems);
}
}
};
</script>
<style>
</style>
Main App component
<template>
<div id="app">
<EditableTable v-model="items" :fields="fields"></EditableTable>
<pre>
{{items}}
</pre>
</div>
</template>
<script>
import EditableTable from './components/EditableTable.vue'
export default {
name: "App",
components: {
EditableTable
},
data() {
return {
fields: [
{ key: "name", label: "Name", type: 'text' },
{ key: "department", label: "Department", type: 'select', options: ['Development', 'Marketing', 'HR', 'Accounting'] },
{ key: "age", label: "Age", type: 'number' },
{ key: "dateOfBirth", label: "Date Of Birth", type: 'date' },
{ key: "edit", label: "", type: "edit" }
],
items: [
{ age: 40, name: 'Dickerson', department: 'Development', dateOfBirth: '1984-05-20' },
{ age: 21, name: 'Larsen', department: 'Marketing', dateOfBirth: '1984-05-20' },
{ age: 89, name: 'Geneva', department: 'HR', dateOfBirth: '1984-05-20' },
{ age: 38, name: 'Jami', department: 'Accounting', dateOfBirth: '1984-05-20' }
]
};
}
};
</script>
<style>
#app {
text-align: center;
margin: 60px;
}
thead, tbody, tfoot, tr, td, th {
text-align: left;
width: 100px;
vertical-align: middle;
}
pre {
text-align: left;
color: #d63384 !important;
}
</style>
By now it’s clear that this approach has less code and above everything, it’s encapsulated and can be used with different table schemas across the whole project.
Final example
Here is an example of how we can create two different table schemas using the same component:
<template>
<div id="app">
<article>
<h3>Employee Information</h3>
<EditableTable v-model="items1" :fields="fields1"></EditableTable>
</article>
<article>
<h3>Employee Contract</h3>
<EditableTable v-model="items2" :fields="fields2"></EditableTable>
</article>
</div>
</template>
<script>
import EditableTable from './components/EditableTable.vue'
export default {
name: "App",
components: {
EditableTable
},
data() {
return {
fields1: [
{ key: "name", label: "Name", type: "text" },
{ key: "department", label: "Department", type: "select", options: ['Development', 'Marketing', 'HR', 'Accounting'] },
{ key: "age", label: "Age", type: "number" },
{ key: "dateOfBirth", label: "Date Of Birth", type: "date" },
{ key: "edit", label: "", type: "edit" }
],
items1: [
{ age: 40, name: 'Dickerson', department: 'Development', dateOfBirth: '1984-05-20' },
{ age: 21, name: 'Larsen', department: 'Marketing', dateOfBirth: '1984-05-20' },
{ age: 89, name: 'Geneva', department: 'HR', dateOfBirth: '1984-05-20' },
{ age: 38, name: 'Jami', department: 'Accounting', dateOfBirth: '1984-05-20' },
],
fields2: [
{ key: "id", label: "ID", type: "number" },
{ key: "name", label: "Name", type: "text" },
{ key: "contractEndDate", label: "Contract End Date", type: "date" },
{ key: "edit", label: "", type: "edit" }
],
items2: [
{ id: 123, name: 'Dickerson', contractEndDate: '2022-05-20' },
{ id: 345, name: 'Larsen', contractEndDate: '2021-05-20' },
{ id: 456, name: 'Geneva', contractEndDate: '2023-05-20' },
{ id: 678, name: 'Jami', contractEndDate: '2024-05-20' }
]
};
}
};
</script>
<style>
#app {
text-align: center;
margin: 60px;
}
thead, tbody, tfoot, tr, td, th {
text-align: left;
width: 100px;
vertical-align: middle;
}
pre {
text-align: left;
color: #d63384;
}
Summary
If you made it to this section, pat yourself on the back! You just learned how to:
- Encapsulate and reuse custom components
- Support dynamic table schema
- Enable
v-model
in a custom component
You can access the complete repository here.
Bye for now 👋