4

Kendo Grid: A Primer For First-Time Users

 2 years ago
source link: https://keyholesoftware.com/2022/02/07/kendo-grid-a-primer-for-first-time-users/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Kendo Grid: A Primer For First-Time Users

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"
  }
]

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK