Muhi Logo Text
AboutBlogWork With Me

[Part 2] Create a Dynamic Table with Bootstrap Vue

Learn how to create a dynamic table and reuse it with different schemas in your Vue project.

Last updated on February 16, 2022

vue
bootstrap vue
Bootstrap Vue

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 and fields)
  • Move the existing code but without the data part (items and fields) 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 👋

If you enjoyed this post, I regularly share similar content on Twitter. Follow me @muhimasri to stay up to date, or feel free to message me on Twitter if you have any questions or comments. I'm always open to discussing the topics I write about!

Recommended Reading

Learn how to validate table rows and input fields with BootstrapVue and HTML form validation.

vue
bootstrap vue

Discussion

Upskill Your Frontend Development Techniques 🌟

Subscribe to stay up-to-date and receive quality front-end development tutorials straight to your inbox!

No spam, sales, or ads. Unsubscribe anytime you wish.

© 2024, Muhi Masri