This is my take on working with Kendo Grid in a Vue 3 project. While I have not explored the grid functionality to the nth degree, these are some things I like about the grid, some difficulties I had implementing it, and some workarounds and quirks I have discovered. Specifically, I will be referring to the Vue implementation of Kendo Grid and the Native Components. This is not to be confused with the Kendo UI for Vue Wrappers.
This article assumes some knowledge of Javascript and Vue 3. Installing an instance of Kendo is not covered here as that info is readily available in the Kendo Docs. A code sample is at the bottom of this article. The code sample is fully commented on and contains examples of all points discussed in this write-up.
Kendo Grid is a very robust tool for displaying data in table format. Out-of-the-box features include sorting, filtering, and pagination. Simply defining the column schema with a few config options will have the grid set up quickly. A few features that require more coding and configuration are column collapsing, adding a toolbar, displaying aggregated fields, using custom cell components, implementing column groups, and exporting grid data to a file.
A note on organization, I’ve titled each section by the rule or aspect it addresses. This (I’m hoping) will make it easier to find exactly what information you’re looking for.
And without further ado, let’s get started!
Each Column Should Have a Data Element
Each column displayed in the grid should have a data field assigned to it. This is needed for sorting and filtering to work. It’s true that Kendo will sort, filter, and display data with regard to the assigned field with no additional code needed. However, you can use a custom cell to display data in a column; sorting and filtering do not use that custom display value. Therefore, it is recommended that a field be assigned to all columns to preserve all functionality.
For example, let’s say a column is to display a full name, but the values in your data object are firstName
, middleName
, and lastName
. You can create a custom component to display the three name values in the cell, but without a field assigned, the grid has no data attribute to interrogate when sorting or filtering on that column.
Normalizing the data before displaying it in the grid is a solution that can solve this issue and several others. Creating a derived field fullName
by concatenating the three name variables will allow you to assign fullName
to a column. With the fullName
assigned, sorting and filtering will work as expected. If only firstName
was assigned to the field, sorting and filtering only execute on the firstName
field.
An out-of-the-box Kendo Grid with a defined column schema will look similar to the following screenshot. This particular implementation also has a toolbar with the Show All Columns
and Clear Filters
buttons. This grid also uses the ColumnMenu
option so every column has a menu in which filtering and sorting can be applied.
Custom Cells
A custom cell can be used by importing a custom component. That component can be assigned to the cell
attribute of a grid column. A custom cell receives a dataItem
property, the field
and className
attributes, and a few others. Attributes title
, width
, and filter
do render in the grid correctly but do not appear as props in a custom cell either.
The dataItem
property contains the entire schema object. You can directly access the desired data elements in your custom cell component with normal object notation. Since you have access to the entire data object, multiple values can be displayed in a single table cell regardless of the field assigned to it.
For example, if you want a unique url path, adding urlPath: /path/to/page
does not work. However, putting urlPath
into the dataItem
will let you access that dataItem
attribute in the custom cell.
Arbitrary Attributes Are Not Accepted In a Custom Cell Component
For example, if you have a date field with a custom DateCell
to render dates in a proper format, being able to display date only or date with time would be a handy feature. However, passing an extra attribute such as showTime: true
in the column schema does not appear to work. As a work around, making a custom DateCell
and also a DateTimeCell
is a simple enough low overhead fix.
//Passing an arbitrary attribute {'showTime': true} does not work as that prop is not available in the custom DateCell component const columns = [ //other column definitions { title: 'Created', field: 'createdDate', filter: 'date', width: '110', cell: DateCell, showTime: false }, { title: 'Paid', field: 'paidDate', filter: 'date', width: '150', cell: DateCell, showTime: true } ]; //Use a custom DateCell and DateTimeCell to render dates properly const columns = [ //other column definitions { title: 'Created', field: 'createdDate', filter: 'date', width: '110', cell: DateCell }, { title: 'Paid', field: 'paidDate', filter: 'date', width: '150', cell: DateTimeCell } ];
Another alternative is to format the values when normalizing the data as mentioned above. This way, a custom cell wouldn’t necessarily be needed to format a value in a specific date format, as currency or any other desired display value.
Expanding/Collapsing Columns From a Parent Table Header
First, you will have to group the columns together into a parent cell. This is easily done by adding child elements in the column schema.
When expanding/collapsing columns from a parent header, sorting still executes. Since attributes such as sortable: false
does not seem to disable the sorting, and other attributes are not made available, I added a -nosort
flag in the field name. Then, in the selectionChange
event I can check if -nosort
is in the field name and if it is, stop the sorting behavior.
The screenshot below shows the expanded Amount
columns.
Defining Schema Filters
Filtering for a column is easily implemented by adding a filter: ‘type’ attribute to the column schema. The supported types are text
, numeric
, and date
. Kendo will automatically display a filter menu with the appropriate inputs based on the filter type value (ex: date pickers for a date filter).
The screenshot below shows the default Kendo grid filter for a text field. Out of the box, Kendo offers a filter UI with a dropdown for the condition (contains, equals, starts with, ends with, etc), an input field, clear, and filter buttons. Also, an additional condition and input can be entered with and/or logic applied. When filters are applied, the grid automatically updates, showing the matching items with proper pagination and the new total items count.
Be careful not to use number
in place of numeric
. This small, innocuous typo leads to a very unhelpful error as shown in the below screenshot.
const columns = [ //filter of 'number' leads to the warning in the screenshot //filter of 'numeric' is the correct value to use { title: 'ID', field: 'id', filter: 'number', width: '110' } ];
Grid Scrolling and Display
Wrapping the grid in some containers with the correct CSS can make it scroll smoothly and as expected. Usually, the grid table rows should scroll while the grid header and footer remain static. It is also normally preferred to display the grid in a content section, modal, or some other viewport.
Depending on how you want a grid to display in the browser, you may need to explicitly set the height of the grid. Setting the grid height is often needed if you want the grid plus all toolbars and pagination buttons to display in a container, whether it be a content section, a modal, or the full browser window. To set the height and maintain responsiveness, add an event listener to set the grid height when the browser window is resized.
Displaying Grid In a Modal
A grid can be displayed in a modal window. However, I noticed some issues with the filter menu when implementing a modal grid. Most modals will have a z-index much greater than 0 to ensure that it displays on top of all other elements. The Kendo Grid filter menu is also displayed on top of the grid. If your modal has a higher z-index than the Kendo Grid filter, the filter will still open as it should when clicking on the column menu icon, but it will not display as it is tucked under the modal window.
To fix this, either set the modal window to a lower z-index or increase the kendo grid menu to a higher z-index.
Conclusion
Kendo Grid takes some time to learn, but it is fairly intuitive and easy to use once you get going. The out of the box sorting, filtering, and pagination features are nice as are the many customization options. It definitely has some weird quirks such as the modal z-indexes, column schema parameters and column groups sorting when set to false. But overall it is a powerful tool and has a wide range of features. I recommend testing it out yourself!
Code Blocks
Below, are code blocks for a Kendo Grid with a defined schema, a function to normalize the data, custom cell components, and other supporting functions. Sometimes it’s easier to learn by seeing actually examples, which is why I wanted to include this section.
Grid.js
The following code block is the Kendo Grid implementation with supporting components and functions. This is the main grid setup file that imports custom cell components, fetches the grid data via an ajax call, normalizes the data, and injects the data into the grid.
<template> <Grid :columns="columns" :column-menu="true" :data-items="processedData" expanded="false" :filter="filter" :pageable="pageable" :page-size="20" reorderable resizable scrollable :skip="skip" :take="take" :total="processedData.total" :sort="sortOrder" sortable @datastatechange="dataStateChange" @toggleColumns="toggleColumns" @toggleAllColumns="toggleAllColumns" > <GridToolbar> <div class="toolbar-items"> <div> <button class="btn btn-sm btn-primary" @click="toggleAllColumns()"> {{showAllColumns ? "Show All Columns" : "Collapse Columns"}} </button> </div> <div> <button class="btn btn-sm btn-info" @click="clearFilters()"> Clear Filters </button> </div> </div> </GridToolbar> </Grid> </template> <script setup> import { ref, onBeforeMount, onMounted, onBeforeUnmount, markRaw } from 'vue'; import axios from 'axios'; //import kendo grid utils import '@progress/kendo-theme-default/dist/all.css'; import { Grid, GridToolbar } from '@progress/kendo-vue-grid'; import { process } from '@progress/kendo-data-query'; import { isNil } from 'lodash'; //import custom grid cells components import LinkCell from '/LinkCell.vue'; import DateCell from '/DateCell.vue'; import DateTimeCell from '/DateTimeCell.vue'; import CurrencyCell from '/CurrencyCell.vue'; import ColumnToggle from '/ColumnToggle.vue'; //variable toggled when showing/hiding all columns let showAllColumns = ref(true); //variable that contains all items currently displayed in the grid //this array is manipulated anytime the grid state is changed (column sorted, field filtered, paginating, etc) const processedData = ref({ data: [] }); //variable containing the data filter object //this variable is manipulated when a column filter is set //clearing all filters entails setting this variable to its original state let filter = ref({ logic: 'and', filters: [] }); //variable containing values used for pagination const pageable = ref({ buttonCount: 1, info: true, type: 'numeric', pageSizes: false, previousNext: true }); //variable containing default field to sort the grid on const sortOrder = ref([{ field: 'id', dir: 'asc' }]); //variables used when paginating thru the grid const skip = ref(0); const take = ref(10); //how many rows appear in each page //variable that contains all grid data const gridData = ref([]); //column headers with the expand/collapse column toggle need the 'nosort' flag so the grid does not sort when clicking show/hide btn const columns = [ //cell that displays the designated field as a hyperlink in a custom cell //the href of the hyperlink is the derived 'urlPath' which is automatically passed to the custom cell as the dataItem { title: 'ID', field: 'id', filter: 'numeric', width: '70', cell: LinkCell }, //plain text field { title: 'Account No', field: 'accountNumber', filter: 'text', width: '130' }, //FULLNAME - a derived field that is set when normalizing the data { title: 'Full Name', field: 'fullName', filter: 'text', width: '160' }, //DATE - grouping of two columns with custom cells to display a formatted date and datetime markRaw({ title: 'Date', sortable: false, children: [ { title: 'Created', field: 'createdDate', filter: 'date', width: '110', cell: DateCell }, { title: 'Paid', field: 'paidDate', filter: 'date', width: '150', cell: DateTimeCell }, ]}), //AMOUNT - grouping of 7 columns in an expandable/collapsable parent header //child cells are numeric fields with a custom cell to display a currency format markRaw({ title: 'Amount', field: 'amountGroup', sortable: false, headerCell: ColumnToggle, children: [ { title: 'Amount', field: 'amount', filter: 'numeric', width: '115', cell: CurrencyCell, hidden: false }, { title: 'Cash', field: 'cashAmount', filter: 'numeric', width: '85', cell: CurrencyCell, hidden: true }, { title: 'Debit', field: 'debitAmount', filter: 'numeric', width: '85', cell: CurrencyCell, hidden: true }, { title: 'Check', field: 'checkAmount', filter: 'numeric', width: '85', cell: CurrencyCell, hidden: true }, { title: 'Fee', field: 'feeAmount', filter: 'numeric', width: '85', cell: CurrencyCell, hidden: true }, { title: 'Surcharge', field: 'surchargeAmount', filter: 'numeric', width: '110', cell: CurrencyCell, hidden: true }, { title: 'Total', field: 'amount', filter: 'numeric', width: '85', cell: CurrencyCell, hidden: true }, ]}), //plain text field { title: 'Status', field: 'status', filter: 'text', width: '100' } ]; //this function is called when the grid state changes - i.e. sorting and applying filters function dataStateChange(event) { filter.value = event.data.filter; sortOrder.value = [...event.data.sort]; skip.value = event.data.skip; take.value = event.data.take; getData(); } //function to take the data and inject it into the grid function getData() { //setting processedData will bind the data from the api call to the grid processedData.value = process(gridData.value, { filter: filter.value, skip: skip.value, sort: sortOrder.value, take: take.value, }); if(isNil(processedData?.value?.data[0]?.selected)) { processedData.value.data = processedData.value.data.map(item => { return { ...item, selected: false } }); } //every time the grid is manipulated, the height must again be set setGridHeight(); } //this function is called by the emit from the ColumnToggle component when expanding/collapsing grouped columns function toggleColumns(group, hidden) { const cellGroup = columns.find(col => col.field === group); cellGroup.children.forEach((col, index) => { //either show only the group detail summary column or all individual columns if (index === 0) { col.hidden = !hidden; } else { col.hidden = hidden; } }); } //this function is called by the emit from the ColumnToggle component when expanding/collapsing all grouped columns function toggleAllColumns() { const groupedCells = columns.filter(group => group.headerCell); groupedCells.forEach(group => { group.children.forEach((col, index) => { //showAll true: hide detail column at index 0, show all other columns //showAll false: show detail column at index 0, hide all other columns if (index === 0) { col.hidden = showAllColumns.value ? true : false; } else { col.hidden = showAllColumns.value ? false : true; } }); }); showAllColumns.value = !showAllColumns.value; getData(); } //convenience function to clear any filters applied and show all grid data function clearFilters() { filter.value = { logic: 'and', filters: [] }; getData(); } //this function takes the data returned from an api call and massages it into the desired data needed for display purposes function normalizeData(data) { const amountFields = ['amount', 'cashAmount', 'checkAmount', 'debitAmount', 'feeAmount', 'surchargeAmount']; let val = null, fullName = []; data.forEach(item => { //derive url path item.urlPath = `/viewitem/${item.id}`; //amounts are in a fixed decimal format: 1234 is 12.34 //dividing by 100 here so amounts displays properly and filtering amounts works amountFields.forEach(field => { val = item[field]; if (!isNaN(val) && val > 0) { item[field] = val / 100; } }); //aggregate firstName, middleName and lastName into a single derived field 'fullName' fullName = []; if (item.firstName) fullName.push(item.firstName); if (item.middleName) fullName.push(item.middleName); if (item.lastName) fullName.push(item.lastName); item.fullName = fullName.join(' '); }); //return data object with normalized and formatted values return [...data]; } //make api call to get the grid data in the onBeforeMount hook onBeforeMount(async () => { axios.get("mockdata.json") .then(response => { //normalize the response data returned gridData.value = normalizeData(response.data); getData(); }).catch(err => { console.log("Error getting data", err); }); }); //this function sets the height of the grid so it takes up the desired amount of space and scrolls properly //this has to be called when the data state changes because kendo redraws the grid in those instances function setGridHeight() { const kgrid = document.getElementsByClassName('k-grid')[0]; if (kgrid) { const height = document.documentElement.clientHeight - 70; //wrap this in a setTimeout so setting of height works properly when paginating, sorting and column groups are expanded/collapsed setTimeout(function() { kgrid.style.height = height + 'px'; }, 5); } } //set the height of the grid on window resize so it scrolls correctly and fits properly in the container onMounted(() => { window.addEventListener('resize', setGridHeight); setGridHeight(); }); //clean up event listeners onBeforeUnmount(() => { window.removeEventListener('resize', setGridHeight); }); </script>
ColumnToggle.js
The following code block is the ColumnToggle
component, which is used to display a button that shows or hides grouped columns on click.
<template> <span> {{title}} <button class=btn btn-sm btn-default @click=toggleColumns()> <icon icon=['fas', hidden ? 'chevron-left' : 'chevron-right'] /> </button> </span> </template> <script> /* This component displays a toggle button for set of grouped columns. Clicking the toggle button shows/hides the corresponding columns and toggles the icon shown. */ import { ref } from 'vue'; export default { props { field { type String, required false, default '' }, title { type String, required false, default '' } }, emits ['toggleColumns'], setup(props, { emit }) { let hidden = ref(false); const toggleColumns = () = { emit('toggleColumns', props.field, hidden.value); hidden.value = !hidden.value; } return { hidden, toggleColumns }; } } </script>
CurrencyCell.js
This component displays a value in US currency format.
<template> <td> {{ formattedCurrency }} </td> </template> <script> /* This component displays a number in US currency format. Ex: value of 12345.67 is displayed as $12,345.67 */ import { computed } from 'vue'; export default { props: { dataItem: { type: Object, required: true, default: () => {} }, field: { type: String, required: true, default: '' } }, setup (props) { const formattedCurrency = computed(() => { if(!props.dataItem[props.field]) return ''; return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD'}).format(props.dataItem[props.field]); }); return { formattedCurrency, } } } </script>
DateCell.js
This custom cell component displays a unix timestamp in a date format.
<template> <td> {{ formattedDate }} </td> </template> <script> /* This component displays a unix timestamp in MM/DD/YYYY format. A forEach loop is used in the computed property to accommodate a nested object. */ import { computed } from 'vue'; import day from 'dayjs'; export default { props: { dataItem: { type: Object, required: true, default: () => {} }, field: { type: String, required: true, default: '' } }, setup (props) { const formattedDate = computed(() => { //date fields can be more than one level deep in the dataItems object //if more than one level deep, the dataItems object must be drilled into //EX 1 level: { dateBirth: 1234567890 } - field is 'dateBirth' //EX 2 level: { user: { dateBirth: 9876543210 } } - field is 'user.dateBirth' const path = props.field.split('.'); let dateVal = props.dataItem; path.forEach((p) => { dateVal = dateVal ? dateVal[p] : null; }); if (!dateVal) return ''; return day(dateVal).format('MM/DD/YYYY'); }); return { formattedDate, } } } </script>
DateTimeCell.js
This custom cell component displays a unix timestamp in a datetime format.
<template> <td> {{ formattedDateTime }} </td> </template> <script> /* This component displays a unix timestamp in MM/DD/YYYY HH:mm format. A forEach loop is used in the computed property to accommodate a nested object. */ import { computed } from 'vue'; import day from 'dayjs'; export default { props: { dataItem: { type: Object, required: true, default: () => {} }, field: { type: String, required: true, default: '' } }, setup (props) { const formattedDateTime = computed(() => { //date fields can be more than one level deep in the dataItems object //if more than one level deep, the dataItems object must be drilled into //EX 1 level: { dateBirth: 1234567890 } - field is 'dateBirth' //EX 2 level: { user: { dateBirth: 9876543210 } } - field is 'user.dateBirth' const path = props.field.split('.'); let dateVal = props.dataItem; path.forEach((p) => { dateVal = dateVal ? dateVal[p] : null; }); if (!dateVal) return ''; return day(dateVal).format('MM/DD/YYYY HH:mm'); }); return { formattedDateTime, } } } </script>
LinkCell.js
This custom cell component displays a hyperlink to a custom url.
<template> <td> <a :href="dataItem.urlPath">{{dataItem[field]}}</a> </td> </template> <script> /* This component displays a hyperlink to a urlPath with the display text of the field passed in. The urlPath for the cell item should be in the dataItem object - this value may have to be derived The field prop is the dataItem attribute that should be displayed. */ export default { props: { dataItem: { type: Object, required: true, default: () => {} }, field: { type: String, required: true, default: '' } } }; </script>
Mock data
Below is mock json data used in this article.
[ { "id": 1, "paidDate": 1635138120000, "createdDate": 1635173899000, "status": "Paid", "firstName": "Bob", "middleName": "M", "lastName": "Jones", "amount": 500999, "cashAmount": 500000, "debitAmount": 0, "checkAmount": 0, "surchargeAmount": 0, "feeAmount": 999, "accountNumber": "1234567890" }, { "id": 2, "paidDate": 1545638000000, "createdDate": 1545673890000, "status": "Open", "firstName": "Chuck", "middleName": "", "lastName": "Testa", "amount": 34557, "cashAmount": 456000, "debitAmount": 3450, "checkAmount": 1230, "surchargeAmount": 0, "feeAmount": 456, "accountNumber": "1-2-3-4" }, { "id": 3, "paidDate": 1451091000000, "createdDate": 1448903000000, "status": "Open", "firstName": "Lee", "middleName": "Van", "lastName": "Cleef", "amount": 29385, "cashAmount": 116000, "debitAmount": 9020, "checkAmount": 1411, "surchargeAmount": 2340, "feeAmount": 888, "accountNumber": "0009911" }, { "id": 4, "paidDate": 1330912390000, "createdDate": 1312090223000, "status": "Unknown", "firstName": "Cleveland", "middleName": "", "lastName": "Brown", "amount": 445113, "cashAmount": 344561, "debitAmount": 345, "checkAmount": 6701, "surchargeAmount": 555, "feeAmount": 378, "accountNumber": "Xrel1234" }, { "id": 5, "paidDate": 1343563453000, "createdDate": 1349098762200, "status": "Closed", "firstName": "Peter", "middleName": "", "lastName": "Griffin", "amount": 23457, "cashAmount": 23450, "debitAmount": 1231, "checkAmount": 4090, "surchargeAmount": 4550, "feeAmount": 1233, "accountNumber": "31SpQuRi" }, { "id": 6, "paidDate": 1491204938570, "createdDate": 1441203049500, "status": "Open", "firstName": "Dale", "middleName": "", "lastName": "Carter", "amount": 55225, "cashAmount": 9290, "debitAmount": 8877, "checkAmount": 4455, "surchargeAmount": 1222, "feeAmount": 432, "accountNumber": "KC34" }, { "id": 7, "paidDate": 1631111120000, "createdDate": 1631112899000, "status": "Paid", "firstName": "Neil", "middleName": "", "lastName": "Smith", "amount": 338212, "cashAmount": 44210, "debitAmount": 3450, "checkAmount": 1210, "surchargeAmount": 0, "feeAmount": 333, "targetAmount": 43213, "accountNumber": "909090" }, { "id": 8, "paidDate": 1599938000000, "createdDate": 1599973890000, "status": "Pending", "firstName": "Adam", "middleName": "Andrew", "lastName": "West", "amount": 22245, "cashAmount": 5210, "debitAmount": 910, "checkAmount": 4255, "surchargeAmount": 330, "feeAmount": 522, "accountNumber": "591ml" }, { "id": 9, "paidDate": 1498771000000, "createdDate": 1440111123321, "status": "Open", "firstName": "Mel", "middleName": "G", "lastName": "Porter", "amount": 61385, "cashAmount": 6000, "debitAmount": 1120, "checkAmount": 711, "surchargeAmount": 1140, "feeAmount": 288, "accountNumber": "Qwer1100" }, { "id": 10, "paidDate": 1331019394500, "createdDate": 1314567123000, "status": "Closed", "firstName": "Bob", "middleName": "Herbert", "lastName": "Ross", "amount": 444421, "cashAmount": 9061, "debitAmount": 488, "checkAmount": 1121, "surchargeAmount": 881, "feeAmount": 112, "accountNumber": "112233" }, { "id": 11, "paidDate": 1390872153000, "createdDate": 1344522195969, "status": "Closed", "firstName": "Paul", "middleName": "", "lastName": "Benson", "amount": 24211, "cashAmount": 52520, "debitAmount": 1456, "checkAmount": 1133, "surchargeAmount": 670, "feeAmount": 1443, "accountNumber": "22331" }, { "id": 12, "paidDate": 1494218172630, "createdDate": 1432456121340, "status": "Pending", "firstName": "Valerio", "middleName": "Dan", "lastName": "Smith", "amount": 5411, "cashAmount": 345, "debitAmount": 678, "checkAmount": 1245, "surchargeAmount": 1000, "feeAmount": 400, "accountNumber": "43123" }, { "id": 13, "paidDate": 1312345678500, "createdDate": 1387654311000, "status": "Closed", "firstName": "Shawn", "middleName": "Justin", "lastName": "Gardner", "amount": 444111, "cashAmount": 5641, "debitAmount": 988, "checkAmount": 1021, "surchargeAmount": 381, "feeAmount": 222, "accountNumber": "1XB360" }, { "id": 14, "paidDate": 1391811153000, "createdDate": 1311223195969, "status": "Pending", "firstName": "Chris", "middleName": "Perry", "lastName": "Mason", "amount": 24551, "cashAmount": 52440, "debitAmount": 1336, "checkAmount": 1223, "surchargeAmount": 1670, "feeAmount": 1678, "accountNumber": "51516" }, { "id": 15, "paidDate": 1434561072630, "createdDate": 1439098761340, "status": "Pending", "firstName": "Steve", "middleName": "Stan", "lastName": "Bono", "amount": 4456, "cashAmount": 7890, "debitAmount": 133, "checkAmount": 6322, "surchargeAmount": 2000, "feeAmount": 701, "accountNumber": "55662" } ]