6 | This is a sample dashboard using Vue Data Table. You can add, edit,
7 | delete and view users.
8 |
9 |
There are three tables to showcase VDT's functionalities.
10 |
11 |
12 |
13 |
19 |
20 |
21 |
TABLE 1
22 |
This table shows a generic dashboard.
23 |
28 |
29 |
TABLE 2
30 |
This table allows editing cells.
31 |
36 |
37 |
TABLE 3
38 |
This table shows lists and images.
39 |
44 |
45 |
46 |
99 |
100 |
101 |
124 |
125 |
126 |
127 |
128 |
--------------------------------------------------------------------------------
/tests/sorting.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "vitest"
2 | import { arraySafeSort } from "../src/utils"
3 | import {
4 | click,
5 | col,
6 | data,
7 | rowText,
8 | searchInput,
9 | testRowsMatchData,
10 | wrapper,
11 | } from "./common"
12 | import { DOMWrapper } from "@vue/test-utils"
13 |
14 | test("it sorts data", async () => {
15 | const keys = ["name", "gender", "job"]
16 | let key: string
17 | let copy: any[]
18 | let i: number
19 | let c: DOMWrapper
20 |
21 | for (i = 0; i < keys.length; i += 1) {
22 | key = keys[i]
23 | c = col(i + 1)
24 |
25 | await click(c)
26 | copy = arraySafeSort(data, (a: any, b: any) =>
27 | a[key].localeCompare(b[key])
28 | )
29 | testRowsMatchData(copy)
30 |
31 | await click(c)
32 | copy = arraySafeSort(data, (b: any, a: any) =>
33 | a[key].localeCompare(b[key])
34 | )
35 | testRowsMatchData(copy)
36 |
37 | await click(c)
38 | testRowsMatchData(data)
39 | }
40 | })
41 |
42 | test("it sorts only one column", async () => {
43 | let arr: any[]
44 |
45 | // sets the sorting mode
46 | await wrapper.setProps({ sortingMode: "single" })
47 |
48 | // // sort by first column
49 | await click(col(1))
50 | arr = arraySafeSort(data, (a: any, b: any) => a.name.localeCompare(b.name))
51 | testRowsMatchData(arr)
52 |
53 | // // sort by second column
54 | await click(col(2))
55 | arr = arraySafeSort(data, (a: any, b: any) =>
56 | a.gender.localeCompare(b.gender)
57 | )
58 | testRowsMatchData(arr)
59 |
60 | // sort by third column
61 | await click(col(3))
62 | arr = arraySafeSort(data, (a: any, b: any) => a.job.localeCompare(b.job))
63 | testRowsMatchData(arr)
64 |
65 | // reverse sort by third column
66 | await click(col(3))
67 | arr = arraySafeSort(data, (a: any, b: any) => b.job.localeCompare(a.job))
68 | testRowsMatchData(arr)
69 |
70 | // reset things
71 | await click(col(3))
72 | testRowsMatchData(data)
73 | await wrapper.setProps({ sortingMode: "multiple" })
74 | })
75 |
76 | test("it sorts filtered data", async () => {
77 | const search = "Executive"
78 | await searchInput.setValue(search)
79 |
80 | // clone the array
81 | const names = data
82 | .filter((x: any) => x.job.includes(search))
83 | .map((x: any) => x.name)
84 | const orderedNames = [...names]
85 |
86 | // sort by first column
87 | await click(col(1))
88 | orderedNames.sort()
89 | expect(rowText(1)).toEqual(orderedNames)
90 |
91 | // sort again, which just reverses the sort
92 | await click(col(1))
93 | orderedNames.reverse()
94 | expect(rowText(1)).toEqual(orderedNames)
95 |
96 | // click the button again cancels sorting
97 | await click(col(1))
98 | expect(rowText(1)).toEqual(names)
99 |
100 | // clear the field afterwards
101 | await wrapper.find(".vdt-search input").setValue("")
102 | })
103 |
104 | test("it sorts multiple rows", async () => {
105 | // copy the data
106 | let copy: any[]
107 |
108 | // sort by second column, then by third column
109 | await click(col(2))
110 | await click(col(3))
111 | copy = arraySafeSort(data, (a: any, b: any) => {
112 | let key = "gender"
113 | if (a[key] == b[key]) key = "job"
114 | return a[key].localeCompare(b[key])
115 | })
116 | testRowsMatchData(copy)
117 |
118 | // reverse sort by third column
119 | await click(col(3))
120 | copy = arraySafeSort(data, (a: any, b: any) => {
121 | let key = "gender"
122 | if (a[key] != b[key]) return a[key].localeCompare(b[key])
123 | key = "job"
124 | return b[key].localeCompare(a[key])
125 | })
126 | testRowsMatchData(copy)
127 |
128 | // reverse sort by second column
129 | await click(col(2))
130 | copy = arraySafeSort(data, (a: any, b: any) => {
131 | let key = "gender"
132 | if (a[key] == b[key]) key = "job"
133 | return b[key].localeCompare(a[key])
134 | })
135 | testRowsMatchData(copy)
136 |
137 | // stop sorting second column
138 | await click(col(2))
139 | copy = arraySafeSort(data, (a: any, b: any) => b.job.localeCompare(a.job))
140 | testRowsMatchData(copy)
141 |
142 | // stop sorting third column
143 | await click(col(3))
144 | testRowsMatchData(data)
145 | })
146 |
147 | test("it sorts only sortable columns", async () => {
148 | await wrapper.setProps({
149 | columns: [
150 | { key: "name", sortable: false },
151 | { key: "gender", sortable: false },
152 | { key: "job" },
153 | ],
154 | })
155 |
156 | // first and second columns are not sortable
157 | await click(col(1))
158 | testRowsMatchData(data)
159 | await click(col(2))
160 | testRowsMatchData(data)
161 |
162 | // third column is sortable
163 | await click(col(3))
164 | const copy = [...data]
165 | copy.sort((a, b) => a.job.localeCompare(b.job))
166 | testRowsMatchData(copy)
167 |
168 | // stop sorting it
169 | await click(col(3))
170 | await click(col(3))
171 |
172 | // reset props
173 | await wrapper.setProps({
174 | columns: [{ key: "name" }, { key: "gender" }, { key: "job" }],
175 | })
176 | })
177 |
--------------------------------------------------------------------------------
/tests/pagination.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "vitest"
2 | import { arraySafeSort } from "../src/utils"
3 | import {
4 | click,
5 | col,
6 | data,
7 | n,
8 | paginationInput,
9 | paginationBtn,
10 | searchInput,
11 | testRowsMatchData,
12 | wrapper,
13 | } from "./common"
14 |
15 | // the per page for the pagination tests
16 | const perPage = 25
17 | const perPageSizes = [25, 50, 100, 200]
18 |
19 | test("it displays correct per page sizes", async () => {
20 | await wrapper.setProps({ perPageSizes })
21 |
22 | const options = wrapper.findAll(".vdt-perpage option")
23 | const values = [] as number[]
24 | for (const option of options as any)
25 | values.push(Number(option.element.value))
26 | expect(values).toEqual(perPageSizes)
27 | })
28 |
29 | test("it sets correct per page sizes", async () => {
30 | await wrapper.setProps({ perPageSizes })
31 |
32 | // test default per page
33 | const select = wrapper.find(".vdt-perpage select") as any
34 | expect(Number(select.element.value)).toBe(perPageSizes[0])
35 |
36 | // TODO: fix code below, which is not working
37 |
38 | const options = select.findAll("option")
39 |
40 | // test rows length with different per page sizes
41 | for (let i = 0; i < options.length; i += 1) {
42 | const option = options[i]
43 | const size = perPageSizes[i]
44 | await select.setValue(option.element.value)
45 | testRowsMatchData(data.slice(0, size))
46 | }
47 | })
48 |
49 | test("it changes pages by clicking on buttons", async () => {
50 | await wrapper.setData({ currentPerPage: perPage })
51 |
52 | // the number of buttons may vary depending on the page,
53 | // that's why we need to use 'let'
54 | const buttons = wrapper.findAll(".vdt-pagination .vdt-page-item")
55 | const l = buttons.length
56 | const prevBtn = buttons[0]
57 | const nextBtn = buttons[l - 1]
58 |
59 | // assert we are in the first page
60 | testRowsMatchData(data.slice(0, perPage))
61 |
62 | // // go to the next page
63 | await click(nextBtn)
64 | testRowsMatchData(data.slice(perPage, perPage * 2))
65 |
66 | // // go to the next page again
67 | await click(nextBtn)
68 | testRowsMatchData(data.slice(perPage * 2, perPage * 3))
69 |
70 | // go back one page
71 | await click(prevBtn)
72 | testRowsMatchData(data.slice(perPage, perPage * 2))
73 |
74 | // first entry of the last page
75 | const lastPage = Math.ceil(n / perPage)
76 | const firstEntry = (lastPage - 1) * perPage
77 |
78 | // go to last page by clicking on the last page button
79 | await click(buttons[l - 2])
80 | testRowsMatchData(data.slice(firstEntry, firstEntry + perPage))
81 |
82 | // go to first page by clicking on the first button
83 | await click(buttons[1])
84 | testRowsMatchData(data.slice(0, perPage))
85 |
86 | // go to the third page by clicking the 'third-page' button
87 | await click(buttons[3])
88 | testRowsMatchData(data.slice(perPage * 2, perPage * 3))
89 |
90 | // go to the first page again
91 | await click(buttons[1])
92 | })
93 |
94 | test("it changes pages by setting current page", async () => {
95 | await wrapper.setData({ currentPerPage: perPage })
96 | const lastPage = paginationInput.attributes("max") as any
97 |
98 | for (let i = lastPage; i >= 1; i -= 1) {
99 | await paginationInput.setValue(i)
100 | await click(paginationBtn)
101 | const end = perPage * i
102 | const start = end - perPage
103 | testRowsMatchData(data.slice(start, end))
104 | }
105 | })
106 |
107 | test("it changes pages on filtered data sorted by multiple columns", async () => {
108 | // set smaller per page sizes
109 | const perPage = 10
110 | await wrapper.setProps({ perPageSizes: [perPage] })
111 | await wrapper.setData({ currentPerPage: perPage })
112 |
113 | // sort by second column, then by third column
114 | await click(col(2))
115 | await click(col(3))
116 |
117 | const searchValues = ["Engineer", "Manager"]
118 | for (const search of searchValues) {
119 | // filter data
120 | await searchInput.setValue(search)
121 | let copy = data.filter((x: any) => x.job.includes(search))
122 |
123 | // sort data
124 | copy = arraySafeSort(copy, (a: any, b: any) => {
125 | let key = "gender"
126 | if (a[key] == b[key]) key = "job"
127 | return a[key].localeCompare(b[key])
128 | })
129 |
130 | const lastPage = Math.ceil(copy.length / perPage)
131 | for (let i = lastPage; i >= 1; i -= 1) {
132 | await paginationInput.setValue(i)
133 | await click(paginationBtn)
134 | const end = perPage * i
135 | const start = end - perPage
136 | testRowsMatchData(copy.slice(start, end))
137 | }
138 | }
139 |
140 | // clear search
141 | await searchInput.setValue("")
142 |
143 | // clear sorting
144 | await click(col(2))
145 | await click(col(2))
146 | await click(col(3))
147 | await click(col(3))
148 | })
149 |
150 | test("it shows all entries at once", async () => {
151 | // Set it to show all.
152 | await wrapper.setProps({ perPageSizes: ["*"] })
153 | testRowsMatchData(data)
154 |
155 | // reset per page sizes
156 | await wrapper.setProps({ perPageSizes })
157 | })
158 |
--------------------------------------------------------------------------------
/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { Cell, Column, CompareFn, Data } from "./types"
2 |
3 | export function toTitleCase(str: string): string {
4 | // convert snake case to title case
5 | str = str.replace(/_/g, " ")
6 |
7 | // convert camel case to title case
8 | str = str.replace(/([a-z])([A-Z])/g, "$1 $2")
9 |
10 | // capitalize first letter of each word
11 | str = str.replace(
12 | /\b\w/g,
13 | w => w[0].toUpperCase() + w.slice(1).toLowerCase()
14 | )
15 |
16 | // return the result
17 | return str
18 | }
19 |
20 | // Replace multiple substrings in the given string from the matching arrays.
21 | export function stringReplaceFromArray(
22 | target: string,
23 | searchValues: string[],
24 | replacements: (string | number)[]
25 | ): string {
26 | for (let i = 0; i < searchValues.length; i++) {
27 | target = target.replace(searchValues[i], "" + replacements[i])
28 | }
29 | return target
30 | }
31 |
32 | export function range(min: number, max: number, step: number = 1): number[] {
33 | const range = []
34 | for (let i = min; i <= max; i += step) {
35 | range.push(i)
36 | }
37 | return range
38 | }
39 |
40 | export function isNullable(variable: any): boolean {
41 | return variable === null || variable === "" || variable === undefined
42 | }
43 |
44 | export function stableSort(arr: T[], compare: CompareFn): T[] {
45 | return arr
46 | .map((item, index) => ({ item, index }))
47 | .sort((a, b) => compare(a.item, b.item) || a.index - b.index)
48 | .map(({ item }) => item)
49 | }
50 |
51 | // Safely compare two items, which may be nullable
52 | export function safeCompare(compareFunction: CompareFn): CompareFn {
53 | return function (a: any, b: any) {
54 | if (isNullable(a)) return 1
55 | if (isNullable(b)) return -1
56 | return compareFunction(a, b)
57 | }
58 | }
59 |
60 | // Safely compare two items by key, which may be nullable
61 | export function safeKeyCompare(compareFunction: CompareFn, key: string) {
62 | return function (a: any, b: any) {
63 | if (isNullable(a[key])) return 1
64 | if (isNullable(b[key])) return -1
65 | return compareFunction(a[key], b[key])
66 | }
67 | }
68 |
69 | // Reverse a comparison function
70 | export function reverseCompare(compareFunction: CompareFn): CompareFn {
71 | return (a: any, b: any) => compareFunction(b, a)
72 | }
73 |
74 | // Performs a case-insensitive comparison of two strings
75 | export function compareStrings(a: string, b: string): number {
76 | return a.toLowerCase().localeCompare(b.toLowerCase())
77 | }
78 |
79 | // Perform a comparison of numeric values (possibly strings)
80 | export function compareNumbers(a: string | number, b: string | number): number {
81 | return Number(a) - Number(b)
82 | }
83 |
84 | // Safely stable sort an array that may have null elements
85 | export function arraySafeSort(array: T[], compareFunction: CompareFn): T[] {
86 | return stableSort(array, safeCompare(compareFunction))
87 | }
88 |
89 | // Sort an array of objects (representing the table) by the given column
90 | export function sortDataByColumns(data: Data, columns: Column[]): Data {
91 | const l = columns.length
92 |
93 | const fn = (a: any, b: any) => {
94 | let i = 0
95 | while (i < l) {
96 | const c = columns[i]
97 | const { sortingMode, compareFunction } = c
98 | let f = compareFunction
99 |
100 | // reverse comparison
101 | const reverseSearch = sortingMode === "desc"
102 |
103 | // get default value for f
104 | if (isNullable(f)) {
105 | const { key, type } = c
106 | if (type === "string") f = compareStrings
107 | if (type === "numeric" || type === "number") f = compareNumbers
108 | if (reverseSearch) f = reverseCompare(f)
109 | // make it safe to search null keys, and put them last
110 | f = safeKeyCompare(f, key)
111 | } else if (reverseSearch) {
112 | f = reverseCompare(f)
113 | }
114 |
115 | // get the result
116 | const result = f(a, b)
117 | if (result !== 0) return result
118 |
119 | // comparison return equal. Proceed to the next comparison
120 | i += 1
121 | }
122 | return 0
123 | }
124 |
125 | return arraySafeSort(data, fn)
126 | }
127 |
128 | /**
129 | * Cross-browser utility to get the event target value
130 | * @returns {*}
131 | */
132 | export function getEventTargetValue(event: any = null) {
133 | event = event || window.event
134 | let target
135 | if (event !== undefined) {
136 | target = event.target || event.srcElement
137 | }
138 | if (target !== undefined) {
139 | return target.value
140 | }
141 | return null
142 | }
143 |
144 | // Performs search on strings
145 | export function searchStringColumn(
146 | data: Cell,
147 | search: string,
148 | key: string
149 | ): boolean {
150 | return ((data[key] || "") as unknown as string)
151 | .toLowerCase().includes(search.toLowerCase())
152 | }
153 |
154 | // Performs search on numeric values
155 | export function searchNumericColumn(
156 | data: Cell,
157 | search: string,
158 | key: string
159 | ): boolean {
160 | return (data[key] || "").toString().includes(search)
161 | }
162 |
--------------------------------------------------------------------------------
/src/demo/App.ts:
--------------------------------------------------------------------------------
1 | import users from "./users.json"
2 | import Swal from "sweetalert2"
3 |
4 | type User = {
5 | bio: string
6 | city: string
7 | company: string
8 | country: string
9 | created_at: string
10 | creditCardNumber: string
11 | creditCardType: string
12 | email: string
13 | email_verified_at: string
14 | fruits: string[]
15 | gender: "Male" | "Female"
16 | id: number
17 | info: string
18 | job: string
19 | name: string
20 | phone: string
21 | photo: string
22 | state: string
23 | streetName: string
24 | suffix: string
25 | timezone: string
26 | title: string
27 | updated_at: string
28 | userAgent: string
29 | username: string
30 | }
31 |
32 | type UserField = keyof User
33 |
34 | const params1 = {
35 | sortingMode: "single",
36 | columns: [
37 | { title: ".", component: "vdt-cell-selectable" },
38 | { key: "name" },
39 | { key: "email", title: "Email address" },
40 | { key: "job" },
41 | {
42 | cssClass: "minwidth",
43 | component: "vdt-actions",
44 | componentProps: { actions: ["view"] },
45 | title: "view",
46 | },
47 | {
48 | cssClass: "minwidth",
49 | component: "vdt-actions",
50 | componentProps: { actions: ["edit"] },
51 | title: "edit",
52 | },
53 | {
54 | cssClass: "minwidth",
55 | component: "vdt-actions",
56 | componentProps: { actions: ["delete"] },
57 | title: "delete",
58 | },
59 | ],
60 | vKey: "id",
61 | }
62 |
63 | const params2 = {
64 | columns: [
65 | { key: "name", editable: true },
66 | { key: "email", editable: true },
67 | { key: "job", editable: true },
68 | {
69 | title: "actions",
70 | cssClass: "minwidth",
71 | component: "vdt-actions",
72 | componentProps: { actions: ["view", "delete"] },
73 | collapsible: true,
74 | },
75 | ],
76 | vKey: "id",
77 | }
78 |
79 | const params3 = {
80 | defaultPerPage: 25,
81 | defaultColumn: { sortable: false },
82 | columns: [
83 | { key: "name" },
84 | {
85 | title: "Top 3 Favorite fruits",
86 | component: "CellList",
87 | searchFunction: (data: any, search: string) => {
88 | return data.fruits.some((f: string) =>
89 | f.toLowerCase().includes(search.toLowerCase())
90 | )
91 | },
92 | searchable: true,
93 | },
94 | {
95 | title: "Image",
96 | component: "CellImage",
97 | cssClass: "minwidth",
98 | collapsible: true,
99 | },
100 | ],
101 | vKey: "id",
102 | }
103 |
104 | export default {
105 | data() {
106 | return {
107 | title: "UPDATE USER",
108 |
109 | // user which will be edited or added
110 | user: {} as User,
111 |
112 | // data for both tables
113 | data: users as User[],
114 |
115 | // selected users
116 | selected: [] as User[],
117 |
118 | // is user being shown/edited?
119 | userView: false,
120 | userEdit: false,
121 |
122 | // parameters for the first table
123 | params1: params1,
124 |
125 | // parameters for the second table
126 | params2: params2,
127 |
128 | // parameters for the third table
129 | params3: params3,
130 | }
131 | },
132 |
133 | methods: {
134 | updateUserField(user: User, field: UserField, value: any) {
135 | const ind = this.data.findIndex((u: User) => u.id === user.id)
136 | if (ind < 0) return
137 | const newUser = { ...this.data[ind] }
138 | newUser[field] = value
139 | this.data.splice(ind, 1, newUser)
140 | },
141 | handleUserEvent(payload: any) {
142 | const user = payload.data as User
143 | switch (payload.action) {
144 | case "delete":
145 | this.showDeleteForm(user)
146 | break
147 | case "edit":
148 | this.showEditForm(user)
149 | break
150 | case "view":
151 | this.showUser(user)
152 | break
153 | case "updateCell":
154 | this.updateUserField(user, payload.key, payload.value)
155 | break
156 | case "select":
157 | this.updateSelection(user, payload.selected)
158 | break
159 | default:
160 | }
161 | },
162 | addUser(user: User) {
163 | user.id = this.data.length + 1
164 | this.data.unshift(user)
165 | this.showSuccessMessage("User added!")
166 | },
167 | deleteUser(user: User) {
168 | this.data = this.data.filter((u: User) => u.id != user.id)
169 | },
170 | deleteSelected() {
171 | const ids = {}
172 | this.selected.forEach((u: User) => (ids[u.id] = true))
173 | this.data = this.data.filter((u: User) => ids[u.id] !== true)
174 | this.selected = []
175 | },
176 | updateUser(user: User) {
177 | const index = this.data.findIndex((u: User) => u.id == user.id)
178 | this.data.splice(index, 1, user)
179 | this.showSuccessMessage("User updated!")
180 | },
181 | updateSelection(user: User, selected: boolean) {
182 | if (selected) {
183 | this.selected.push(user)
184 | } else {
185 | this.selected = this.selected.filter(
186 | (u: User) => u.id != user.id
187 | )
188 | }
189 | },
190 | showUser(user: User) {
191 | this.user = { ...user }
192 | this.title = user.name
193 | this.userView = true
194 | },
195 | showEditForm(user: User) {
196 | this.title = "UPDATE USER"
197 | this.user = { ...user }
198 | this.userEdit = true
199 | },
200 | showCreateForm() {
201 | this.title = "CREATE USER"
202 | this.user = { name: "", email: "", job: "", gender: "", info: "" }
203 | this.userEdit = true
204 | },
205 | showDeleteForm(user: User) {
206 | Swal.fire({
207 | title: "Are you sure?",
208 | text: "You are about to delete this user!",
209 | icon: "warning",
210 | showCancelButton: true,
211 | confirmButtonColor: "#3085d6",
212 | cancelButtonColor: "#d33",
213 | confirmButtonText: "Yes, delete it!",
214 | }).then(result => {
215 | if (result.isConfirmed) {
216 | this.deleteUser(user)
217 | this.showSuccessMessage("User deleted!")
218 | }
219 | })
220 | },
221 | showSuccessMessage(message: string) {
222 | Swal.fire({
223 | title: "SUCCESS!",
224 | text: message,
225 | icon: "success",
226 | position: "bottom-end",
227 | toast: true,
228 | timer: 3000,
229 | showConfirmButton: false,
230 | })
231 | },
232 | submitForm() {
233 | const user = this.user
234 | this.userEdit = false
235 | if (user.id != null) this.updateUser(user)
236 | else this.addUser(user)
237 | },
238 | },
239 | }
240 |
--------------------------------------------------------------------------------
/src/components/DataTable.ts:
--------------------------------------------------------------------------------
1 | import VdtEntriesInfo from "./EntriesInfo/EntriesInfo.vue"
2 | import VdtExportData from "./ExportData/ExportData.vue"
3 | import VdtPagination from "./Pagination/Pagination.vue"
4 | import VdtPerPage from "./PerPage/PerPage.vue"
5 | import VdtSearchFilter from "./SearchFilter/SearchFilter.vue"
6 | import VdtTable from "./Table/Table.vue"
7 |
8 | import {
9 | range,
10 | isNullable,
11 | sortDataByColumns,
12 | stringReplaceFromArray,
13 | getEventTargetValue,
14 | } from "../utils"
15 | import { parseColumnProps, parseTextProps } from "../parser"
16 |
17 | import { defineComponent, reactive } from "vue"
18 | import { SORTING_MODE } from "../const"
19 | import type { Column, Data } from "../types"
20 |
21 | export default defineComponent({
22 | name: "VueDataTable",
23 |
24 | components: {
25 | VdtEntriesInfo,
26 | VdtExportData,
27 | VdtPagination,
28 | VdtPerPage,
29 | VdtSearchFilter,
30 | VdtTable,
31 | },
32 |
33 | props: {
34 | allowedExports: { type: Array, default: () => ["csv", "json", "xml"] },
35 | columns: { type: Array, required: false },
36 | columnKeys: { type: Array, required: false },
37 | data: { type: Array, required: false },
38 | defaultColumn: { type: Object, required: false, default: () => ({}) },
39 | defaultPerPage: { type: Number, default: 10 },
40 | downloadFileName: { type: String, default: "download" },
41 | fetchUrl: { type: String, required: false },
42 | fetchCallback: { type: Function, required: false },
43 | footerComponent: { type: [Object, String], default: null },
44 | isLoading: { type: Boolean, default: false },
45 | lang: { type: String, default: "en" },
46 | loadingComponent: { type: [Object, String], default: () => "" },
47 | perPageSizes: { type: Array, default: () => [10, 25, 50, 100, "*"] },
48 | showEntriesInfo: { type: Boolean, default: true },
49 | showPerPage: { type: Boolean, default: true },
50 | showDownloadButton: { type: Boolean, default: true },
51 | showPagination: { type: Boolean, default: true },
52 | showSearchFilter: { type: Boolean, default: true },
53 | sortingMode: {
54 | type: String,
55 | default: "multiple",
56 | validator: (value: string) => {
57 | return ["multiple", "single", "none"].includes(value)
58 | },
59 | },
60 | sortingIndexComponent: {
61 | type: [Object, String],
62 | default: "vdt-sorting-index",
63 | },
64 | sortingIconComponent: {
65 | type: [Object, String],
66 | default: "vdt-sorting-icon",
67 | },
68 | tableClass: {
69 | type: String,
70 | default: "table table-striped table-hover",
71 | },
72 | text: { type: Object, required: false },
73 | vKey: { type: String, default: "" },
74 | },
75 |
76 | emits: ["userEvent"],
77 |
78 | data: () => {
79 | return reactive({
80 | dataFetched: [] as Data,
81 | dataFetchedLinks: [] as any[],
82 | currentPage: 1,
83 | currentPerPage: 10,
84 | parsedColumns: [] as Column[],
85 | columnsBeingSorted: [] as Column[],
86 | perPageText: "",
87 | perPageAllText: "",
88 | downloadText: "",
89 | downloadButtonText: "",
90 | emptyTableText: "",
91 | infoText: "",
92 | infoAllText: "",
93 | infoFilteredText: "",
94 | nextButtonText: "",
95 | previousButtonText: "",
96 | paginationSearchText: "",
97 | paginationSearchButtonText: "",
98 | search: "",
99 | searchText: "",
100 | totalRecords: 0,
101 | })
102 | },
103 |
104 | computed: {
105 | actualData() {
106 | return this.data != null ? this.data : this.dataFetched
107 | },
108 |
109 | /**
110 | * Get the total number of columns
111 | */
112 | numberOfColumns() {
113 | return this.parsedColumns.length
114 | },
115 |
116 | /**
117 | * Get the columns that can be used in searches
118 | */
119 | searchableColumns() {
120 | return this.parsedColumns.filter(
121 | (column: Column) => column.searchable
122 | )
123 | },
124 |
125 | /**
126 | * Get the columns that can be sorted
127 | */
128 | sortableColumns() {
129 | return this.parsedColumns.filter(
130 | (column: Column) => column.sortable
131 | )
132 | },
133 |
134 | //
135 | // ─── DATA ────────────────────────────────────────────────────────────
136 | //
137 |
138 | /**
139 | * The data displayed in the current table page
140 | */
141 | dataDisplayed() {
142 | const { lastEntry, firstEntry, dataSorted } = this
143 | const end = lastEntry
144 | const start = Math.max(0, firstEntry - 1)
145 | return dataSorted.slice(start, end)
146 | },
147 |
148 | /**
149 | * The data filtered by search text
150 | */
151 | dataFiltered() {
152 | const { searchableColumns, search } = this
153 |
154 | // assign key to track row
155 | const key = this.vKey
156 | const data = this.actualData.map((value: any, index) => {
157 | if (key !== "" && value[key]) {
158 | index = value[key]
159 | }
160 | return { ...(value as object), _key: index }
161 | })
162 |
163 | if (isNullable(search)) {
164 | return data
165 | }
166 |
167 | return data.filter(function (row: any) {
168 | return searchableColumns.some(function (column: Column) {
169 | return column.searchFunction(row, search, column.key)
170 | })
171 | })
172 | },
173 |
174 | /**
175 | * The data after sorting it by the desirable columns
176 | */
177 | dataSorted() {
178 | const { dataFiltered: data, columnsBeingSorted } = this
179 |
180 | // do not sort if there is no rows or no data to sort
181 | if (columnsBeingSorted.length === 0 || data.length === 0) {
182 | return data
183 | }
184 |
185 | return sortDataByColumns(
186 | data as unknown as Data,
187 | columnsBeingSorted
188 | )
189 | },
190 |
191 | /**
192 | * Indicates if there are no rows to shown
193 | */
194 | isEmpty() {
195 | if (!this.data) return this.dataFetched.length === 0
196 | else return this.dataDisplayed.length === 0
197 | },
198 |
199 | //
200 | // ─── PER PAGE ────────────────────────────────────────────────────────
201 | //
202 |
203 | /**
204 | * Get the index of the first record being displayed in the current page
205 | */
206 | firstEntry() {
207 | const { dataFiltered, currentPerPage, currentPage } = this
208 | if (
209 | dataFiltered.length === 0 ||
210 | (currentPerPage as number | string) === "*"
211 | ) {
212 | return 0
213 | }
214 | return currentPerPage * (currentPage - 1) + 1
215 | },
216 |
217 | /**
218 | * Get the index of the last record being displayed in the current page
219 | */
220 | lastEntry() {
221 | const { currentPerPage } = this
222 | if ((currentPerPage as number | string) === "*") {
223 | return this.filteredEntries
224 | }
225 | return Math.min(
226 | this.filteredEntries,
227 | this.firstEntry + currentPerPage - 1
228 | )
229 | },
230 |
231 | /**
232 | * Get the number of records
233 | */
234 | totalEntries() {
235 | if (this.data == null) return this.totalRecords
236 | else return this.actualData.length
237 | },
238 |
239 | /**
240 | * Get the number of records filtered
241 | */
242 | filteredEntries() {
243 | if (this.data == null) return this.totalRecords
244 | return this.dataFiltered.length
245 | },
246 |
247 | /**
248 | * The text containing how many rows are being shown
249 | */
250 | entriesInfoText() {
251 | const {
252 | currentPerPage,
253 | infoText,
254 | infoAllText,
255 | infoFilteredText,
256 | firstEntry,
257 | lastEntry,
258 | filteredEntries,
259 | totalEntries,
260 | } = this
261 | const replacements = [
262 | firstEntry,
263 | lastEntry,
264 | filteredEntries,
265 | totalEntries,
266 | ]
267 | if ((currentPerPage as number | string) === "*") {
268 | return infoAllText
269 | }
270 | const searchValues = [":first", ":last", ":filtered", ":total"]
271 | let text = infoText
272 | if (totalEntries !== filteredEntries) {
273 | text = infoFilteredText
274 | }
275 | // we take the text provided by the user, then
276 | // replace the placeholders with the actual
277 | // values, and return the result
278 | return stringReplaceFromArray(text, searchValues, replacements)
279 | },
280 |
281 | //
282 | // ─── PAGINATION ──────────────────────────────────────────────────────
283 | //
284 |
285 | /**
286 | * Get the number of pages
287 | */
288 | numberOfPages() {
289 | const { currentPerPage } = this
290 | if ((currentPerPage as number | string) === "*") return 1
291 | return Math.max(
292 | Math.ceil(this.filteredEntries / this.currentPerPage),
293 | 1
294 | )
295 | },
296 |
297 | /**
298 | * Alias for the number of pages
299 | */
300 | lastPage() {
301 | return this.numberOfPages
302 | },
303 |
304 | /**
305 | * Whether this is the last page of the table
306 | */
307 | isLastPage() {
308 | return this.currentPage === this.numberOfPages
309 | },
310 |
311 | /**
312 | * Whether this is the first page of the table
313 | */
314 | isFirstPage() {
315 | return this.currentPage === 1
316 | },
317 |
318 | /**
319 | * Get the number of the previous page
320 | */
321 | previousPage() {
322 | return this.currentPage - 1
323 | },
324 |
325 | /**
326 | * Get the number of the next page
327 | */
328 | nextPage() {
329 | return this.currentPage + 1
330 | },
331 |
332 | /**
333 | * Get the text to be shown in pagination menu
334 | */
335 | pagination() {
336 | // extract the variables from "this"
337 | // so we don't have to type this.prop
338 | // every time we access it.
339 | const { lastPage, currentPage, nextPage, previousPage } = this
340 | if (lastPage === 1) {
341 | return [1]
342 | }
343 | if (lastPage <= 7) {
344 | return range(1, lastPage)
345 | }
346 | if (lastPage > 7 && currentPage <= 4) {
347 | return [1, 2, 3, 4, 5, "...", lastPage]
348 | }
349 | if (lastPage > 8 && lastPage > currentPage + 3) {
350 | return [
351 | 1,
352 | "...",
353 | previousPage,
354 | currentPage,
355 | nextPage,
356 | "...",
357 | lastPage,
358 | ]
359 | }
360 | if (lastPage > 7 && lastPage <= currentPage + 3) {
361 | return [
362 | 1,
363 | "...",
364 | lastPage - 3,
365 | lastPage - 2,
366 | lastPage - 1,
367 | lastPage,
368 | ]
369 | }
370 | throw new Error("INVALID PAGE RANGE")
371 | },
372 |
373 | // ─────────────────────────────────────────────────────────────────────
374 | //
375 |
376 | /**
377 | * The props for the PerPage component
378 | */
379 | propsPerPage() {
380 | return {
381 | currentPerPage: this.currentPerPage,
382 | perPageSizes: this.perPageSizes,
383 | perPageText: this.perPageText,
384 | perPageAllText: this.perPageAllText,
385 | }
386 | },
387 |
388 | /**
389 | * The props for the SearchFilter component
390 | */
391 | propsSearchFilter() {
392 | return { search: this.search, searchText: this.searchText }
393 | },
394 |
395 | /**
396 | * The props for the Table component
397 | */
398 | propsTable() {
399 | const dataNotNull = this.data != null
400 | const data = dataNotNull ? this.data : this.dataFetched
401 | const dataDisplayed = dataNotNull
402 | ? this.dataDisplayed
403 | : this.dataFetched
404 | const dataFiltered = dataNotNull
405 | ? this.dataFiltered
406 | : this.dataFetched
407 | return {
408 | columns: this.parsedColumns,
409 | data: data,
410 | dataDisplayed: dataDisplayed,
411 | dataFiltered: dataFiltered,
412 | emptyTableText: this.emptyTableText,
413 | footerComponent: this.footerComponent,
414 | isEmpty: this.isEmpty,
415 | isLoading: this.isLoading,
416 | loadingComponent: this.loadingComponent,
417 | numberOfColumns: this.numberOfColumns,
418 | sortingIconComponent: this.sortingIconComponent,
419 | sortingIndexComponent: this.sortingIndexComponent,
420 | tableClass: this.tableClass,
421 | }
422 | },
423 |
424 | /**
425 | * The props for the EntriesInfo component
426 | */
427 | propsEntriesInfo() {
428 | return { entriesInfoText: this.entriesInfoText }
429 | },
430 |
431 | /**
432 | * The props for the Pagination component
433 | */
434 | propsPagination() {
435 | return {
436 | currentPage: this.currentPage,
437 | isFirstPage: this.isFirstPage,
438 | isLastPage: this.isLastPage,
439 | nextButtonText: this.nextButtonText,
440 | nextPage: this.nextPage,
441 | numberOfPages: this.numberOfPages,
442 | pagination: this.pagination,
443 | paginationSearchButtonText: this.paginationSearchButtonText,
444 | paginationSearchText: this.paginationSearchText,
445 | previousButtonText: this.previousButtonText,
446 | previousPage: this.previousPage,
447 | }
448 | },
449 |
450 | /**
451 | * The props for the DownloadButton component
452 | */
453 | propsExportData() {
454 | return {
455 | allowedExports: this.allowedExports,
456 | data: this.dataDisplayed,
457 | downloadButtonText: this.downloadButtonText,
458 | downloadFileName: this.downloadFileName,
459 | downloadText: this.downloadText,
460 | }
461 | },
462 | },
463 |
464 | watch: {
465 | columns: { handler: "parseColumnProps", deep: true, immediate: true },
466 | columnKeys: {
467 | handler: "parseColumnProps",
468 | deep: true,
469 | immediate: true,
470 | },
471 | columnsBeingSorted: {
472 | handler: "updateData",
473 | deep: false,
474 | immediate: false,
475 | },
476 | text: { handler: "parseTextProps", deep: true, immediate: true },
477 | lang: { handler: "parseTextProps" },
478 | perPageSizes: { handler: "setDefaults" },
479 | },
480 |
481 | mounted() {
482 | this.setDefaults()
483 | this.updateData()
484 | },
485 |
486 | methods: {
487 | /**
488 | * Update data, fetching it if needed.
489 | * If all data was previously fetched, it is stored in state variables,
490 | * therefore nothing is done in that case.
491 | */
492 | async updateData() {
493 | if (this.data === null || this.data === undefined) this.fetchData()
494 | },
495 |
496 | async fetchData(url = "") {
497 | if (this.fetchUrl == null || this.fetchCallback == null)
498 | throw Error("Fetch parameters are null")
499 |
500 | // empty URL but we have the URL stored
501 | if (url === "" && this.dataFetchedLinks.length > 1) {
502 | url =
503 | this.dataFetchedLinks[this.currentPage].url +
504 | this.getSearchQuery() +
505 | this.getSortQuery()
506 | }
507 |
508 | // initial URL
509 | if (url === "") {
510 | url = this.fetchUrl
511 | }
512 |
513 | this.fetchCallback(url).then((responseData: any) => {
514 | // Laravel API.
515 | // If response is from ResourceCollection,
516 | // then the metadata is in a nested object called meta.
517 | // Otherwise, the metadata is directly in the JSON response.
518 | const { data } = responseData
519 | const meta = responseData.meta ?? responseData
520 | this.dataFetched = data
521 | this.dataFetchedLinks = meta.links
522 | this.currentPage = meta.current_page
523 | this.currentPerPage = meta.per_page
524 | this.totalRecords = meta.total
525 | })
526 | },
527 |
528 | /**
529 | * Propagate upwards an event from user's custom component
530 | */
531 | emitUserEvent(payload: any) {
532 | this.$emit("userEvent", payload)
533 | },
534 |
535 | /**
536 | * Indicates if a page is valid
537 | */
538 | isValidPage(page: number | string): boolean {
539 | return (
540 | typeof page === "number" &&
541 | page <= this.numberOfPages &&
542 | page > 0 &&
543 | page !== this.currentPage
544 | )
545 | },
546 |
547 | /**
548 | * Parse columns (assign default values while enabling customization)
549 | */
550 | parseColumnProps() {
551 | this.parsedColumns = parseColumnProps(this.$props)
552 | },
553 |
554 | /**
555 | * Parse the text (choose correct translation while enabling custom text)
556 | */
557 | parseTextProps() {
558 | Object.assign(this, parseTextProps(this.$props))
559 | },
560 |
561 | /**
562 | * Toggle the sorting state of the given column.
563 | *
564 | * This actually does not sort the column, but only set the state of the
565 | * column, as well as the state of the other columns affected by it.
566 | */
567 | sortColumn(column: Column) {
568 | // column is not sortable, ignore it
569 | if (!column.sortable) {
570 | return
571 | }
572 |
573 | if (this.sortingMode === "none") {
574 | return
575 | }
576 |
577 | // case when the current mode is to only sort a single column
578 | if (this.sortingMode === "single") {
579 | // mark other columns as not being sorted
580 | // skipping the current column
581 | for (const col of this.sortableColumns as Column[]) {
582 | if (col.id !== column.id) {
583 | col.sortingMode = SORTING_MODE.NONE
584 | col.sortingIndex = -1
585 | }
586 | }
587 |
588 | // the column is not being sorted
589 | // so, mark it as sorted in ascending mode
590 | if (column.sortingMode === SORTING_MODE.NONE) {
591 | column.sortingMode = SORTING_MODE.ASC
592 | this.columnsBeingSorted = [column]
593 | return
594 | }
595 |
596 | // the column is being sorted in ascending mode
597 | // so, mark it as sorted in descending mode
598 | if (column.sortingMode === SORTING_MODE.ASC) {
599 | column.sortingMode = SORTING_MODE.DESC
600 | this.columnsBeingSorted = [column]
601 | return
602 | }
603 |
604 | // column is being sorted in descending mode
605 | // so, mark it as not being sorted
606 | column.sortingMode = SORTING_MODE.NONE
607 | this.columnsBeingSorted = []
608 | return
609 | }
610 |
611 | // column is not being sorted
612 | // so, mark it as sorted in ascending mode
613 | if (column.sortingMode === SORTING_MODE.NONE) {
614 | column.sortingMode = SORTING_MODE.ASC
615 | column.sortingIndex = this.columnsBeingSorted.length + 1
616 | this.columnsBeingSorted.push(column)
617 | return
618 | }
619 |
620 | // column is being sorted in ascending mode
621 | // so, mark it as sorted in descending mode
622 | if (column.sortingMode === SORTING_MODE.ASC) {
623 | column.sortingMode = SORTING_MODE.DESC
624 | this.columnsBeingSorted.splice(
625 | column.sortingIndex - 1,
626 | 1,
627 | column
628 | )
629 | return
630 | }
631 |
632 | // column is being sorted in descending mode
633 | // so, mark it as not being sorted
634 | column.sortingMode = SORTING_MODE.NONE
635 | column.sortingIndex = -1
636 | this.columnsBeingSorted = this.columnsBeingSorted.filter(
637 | (c: Column) => {
638 | return c.id !== column.id
639 | }
640 | )
641 |
642 | // in this case,
643 | // it is necessary to update the sorting index of other columns
644 | // to reflect the fact that there is one less column.
645 | this.columnsBeingSorted.forEach(function (col: Column, i: number) {
646 | col.sortingIndex = i + 1
647 | })
648 | },
649 |
650 | /**
651 | * Set the default values of some attributes
652 | */
653 | setDefaults() {
654 | this.setPerPage(this.defaultPerPage)
655 | },
656 |
657 | /**
658 | * Set the current page being displayed
659 | */
660 | setPage(value: number | string) {
661 | if (!this.isValidPage(value)) {
662 | return
663 | }
664 | this.currentPage = Number(value)
665 | this.updateData()
666 | },
667 |
668 | /**
669 | * Set the current rows per page
670 | */
671 | setPerPage(value: any) {
672 | let newPerPage, newCurrentPage
673 | const previousFirstEntry = this.firstEntry
674 |
675 | // before updating the value of currentPerPage,
676 | // we need to store the current firstEntry.
677 | // We will use it to change the current page.
678 | newPerPage = this.currentPerPage
679 |
680 | if (!this.perPageSizes.includes(newPerPage)) {
681 | newPerPage = this.perPageSizes[0]
682 | }
683 | if (this.perPageSizes.includes(value)) {
684 | newPerPage = value
685 | }
686 | this.currentPerPage = newPerPage
687 |
688 | // update current per page so that
689 | // the user will see the same first
690 | // rows that were being displayed
691 | if ((this.currentPerPage as number | string) === "*") {
692 | newCurrentPage = 1
693 | } else {
694 | newCurrentPage = Math.floor(previousFirstEntry / newPerPage) + 1
695 | }
696 | this.setPage(newCurrentPage)
697 | },
698 |
699 | /**
700 | * Set the current rows per page from the user input
701 | */
702 | setPerPageFromUserInput() {
703 | let value = getEventTargetValue()
704 | if (value !== "*") value = Number(value)
705 | this.setPerPage(value)
706 | },
707 |
708 | /**
709 | * Set the value being searched
710 | */
711 | setSearch() {
712 | const value = getEventTargetValue() || ""
713 | this.search = value.trim()
714 | this.currentPage = 1
715 | this.updateData()
716 | },
717 |
718 | /**
719 | * Get search query URI for fetching data.
720 | *
721 | * @returns string
722 | */
723 | getSearchQuery() {
724 | const encodedSearch = encodeURIComponent(this.search)
725 | let searchQueryUri = ""
726 | this.searchableColumns.forEach((col: Column) => {
727 | if (col.key) {
728 | searchQueryUri += `&filter[${col.key}]=${encodedSearch}`
729 | }
730 | })
731 | return searchQueryUri
732 | },
733 |
734 | /**
735 | * Return the sort query URI for fetching data.
736 | *
737 | * @returns string
738 | */
739 | getSortQuery() {
740 | const { columnsBeingSorted } = this
741 |
742 | // nothing being sorted
743 | if (columnsBeingSorted.length == 0) return ""
744 |
745 | let searchQueryUri = "&sort="
746 | const descPrefix = "-"
747 | const sep = ","
748 | columnsBeingSorted.forEach((col: Column) => {
749 | if (col.sortingMode == SORTING_MODE.DESC)
750 | searchQueryUri += descPrefix
751 | searchQueryUri += col.key + sep
752 | })
753 | return searchQueryUri
754 | },
755 | },
756 | })
757 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VUE DATA TABLE
2 |
3 | `VueDataTable` is a Vue plugin to easily create fully-featured data tables.
4 |
5 |
6 |
7 | * [FEATURES](#features)
8 | * [DEMO](#demo)
9 | * [GETTING STARTED](#getting-started)
10 | * [Installation](#installation)
11 | * [Set up](#set-up)
12 | * [Usage](#usage)
13 | * [Nuxt integration](#nuxt-integration)
14 | * [Laravel integration](#laravel-integration)
15 | * [CONFIGURATION](#configuration)
16 | * [Columns](#columns)
17 | * [Custom Cell Component](#custom-cell-component)
18 | * [Action Buttons](#action-buttons)
19 | * [Editable cells](#editable-cells)
20 | * [Selectable rows](#selectable-rows)
21 | * [Text](#text)
22 | * [Adding Language](#adding-language)
23 | * [Async data](#async-data)
24 | * [Layout](#layout)
25 | * [Custom Components](#custom-components)
26 | * [Footer](#footer)
27 | * [Sort Icon](#sort-icon)
28 | * [Sorting Index Icon](#sorting-index-icon)
29 | * [ROADMAP](#roadmap)
30 | * [LICENSE](#license)
31 | * [CONTRIBUTING](#contributing)
32 |
33 |
34 |
35 | Check out my other plugin, [vue-form-builder](https://github.com/uwla/vue-form-builder),
36 | that automatically generates beautiful forms from declarative rules.
37 |
38 | ## FEATURES
39 |
40 | - Pagination
41 | - Search filter
42 | - Single column sorting
43 | - Multiple column sorting
44 | - Customize every visible text
45 | - Support for multiple languages
46 | - Export data (JSON, CVS, TXT or XLS)
47 | - Action buttons (view, edit, delete)
48 | - Editable cells (edit cell values)
49 | - Custom Vue components to render cells
50 | - Custom Footer to display data summary
51 | - Support for Vue3 and Vue2
52 | - Nuxt integration
53 | - Laravel integration
54 |
55 | ## DEMO
56 |
57 | The best way to see if a package suits your needs is by viewing and testing its
58 | functionalities via a [demo app](https://uwla.github.io/vue-data-table/demo).
59 |
60 | There is also this [CodeSandbox Playground](https://codesandbox.io/s/vue3-data-table-demo-c7kfj5)
61 | in which you can edit the source code with live preview.
62 |
63 | 
64 | 
65 | 
66 |
67 | ## GETTING STARTED
68 |
69 | ### Installation
70 |
71 | ```shell
72 | npm install @uwlajs/vue-data-table
73 | ```
74 |
75 | Make sure to install version `2.0.0` or above for Vue3.
76 |
77 | Versions prior to `2.0.0` are for Vue2. Checkout the `vue2` branch for its documentation.
78 |
79 | ### Set up
80 |
81 | ```javascript
82 | import VueDataTable from "@uwlajs/vue-data-table";
83 | Vue.component("vue-data-table", VueDataTable);
84 | ```
85 |
86 | Don"t forget to add the style sheets
87 |
88 | ```javascript
89 | import "@uwlajs/vue-data-table/dist/VueDataTable.css";
90 | ```
91 |
92 | ### Usage
93 |
94 | ```vue
95 |
96 |
97 |
98 |
99 |
100 |
113 | ```
114 |
115 | **Note** Notice that v-bind will take all key-value pairs in the object (in this
116 | case, the `bindings`), and pass them as props to the `VueDataTable.` So, this is
117 | a shortcut to pass multiple props at once.
118 |
119 | ### Nuxt integration
120 |
121 | Create a file `@/plugins/vue-data-table.js`, or whatever name you wish, with the following content:
122 |
123 | ```javascript
124 | import VueDataTable from '@uwlajs/vue-data-table'
125 | import '@uwlajs/vue-data-table/dist/style.css'
126 |
127 | export default defineNuxtPlugin(nuxtApp => {
128 | nuxtApp.vueApp.use(VueDataTable)
129 | })
130 | ```
131 |
132 | Nuxt automatically loads the files in the `plugins/` directory by default.
133 |
134 | ### Laravel integration
135 |
136 | This plugin integrates with Laravel's pagination API, so it fetches data
137 | asynchronously from the provided URL. Follow the instrunctions in the
138 | [async data section](#async-data) for a detailed setup.
139 |
140 | ## CONFIGURATION
141 |
142 | Only `columns` are required. Other props are optional.
143 |
144 | If `data` is not passed, then `fetchUrl` and `fetchCallback` *must* be passed.
145 |
146 | `vKey` is not required but is **highly** recommend to set it if you plan to
147 | add or delete rows in the table!
148 |
149 | | prop | type | default | description |
150 | | --------------------- | ------------------ | --------------------------------- | ------------------------------------------------------------------------------------------ |
151 | | allowedExports | `Array` | `["csv", "json", "txt"]` | Formats the user can export the data to. Allowed values: `csv`, `json`, `txt`, `xlsx` |
152 | | data | `Array` | - | Array of objects with the data to be displayed on the table |
153 | | columns | `Array` | - | Array of objects to specify how to render each column. Optional if `columnKeys` is set |
154 | | columnKeys | `Array` | - | Array of strings matching the object keys in `data`. Discarded if `columns` is set |
155 | | lang | `String` | `en` | The default language |
156 | | perPageSizes | `Array` | [10, 25, 50, 100, '*'] | The options for the number of rows being displayed per page. The string '*' shows all. |
157 | | defaultPerPage | `Number` | 10 | The default number of entries. If unset, then it will be the first value of `perPageSizes` |
158 | | fetchUrl | `String` | - | The URL to fetch data from if `data` is null |
159 | | fetchCallback | `String` | - | Async function which takes an URL and returns `data` matching Laravel's pagination API |
160 | | isLoading | `Bool` | `false` | Whether table data is loading. Table rows are shown only if this value is set to `false` |
161 | | loadingComponent | `String`, `Object` | - | VueJS component to be shown if `isLoading` is set to `true` |
162 | | showPerPage | `Bool` | `true` | Whether to show the `PerPage` component |
163 | | showEntriesInfo | `Bool` | `true` | Whether to show the `EntriesInfo` component |
164 | | showSearchFilter | `Bool` | `true` | Whether to show the `SearchFilter` component |
165 | | showPagination | `Bool` | `true` | Whether to show the `Pagination` component |
166 | | showDownloadButton | `Bool` | `true` | Whether to show the button to download the table's data |
167 | | tableClass | `String` | `table table-striped table-hover` | The table's HTML `class` attribute |
168 | | sortingMode | `String` | `multiple` | Whether to sort a single column or multiple columns at once |
169 | | sortingIndexComponent | `String`, `Object` | `VdtSortingIndex` | VueJS component for the sorting index on sortable columns |
170 | | sortingIconComponent | `String`, `Object` | `VdtSortingIcon` | VueJS component for the sorting icon on sortable columns |
171 | | footerComponent | `String`, `Object` | `null` | VueJS component for custom table footer |
172 | | vKey | `String` | - | The `v-key`, the key in `data` used by Vue to track and distinguish array elements. |
173 |
174 | ### Columns
175 |
176 | | key | type | default | description |
177 | | --------------- | ------------------ | ---------------- | --------------------------------------------------------- |
178 | | key | `String` | - | The object field to be displayed in a table cell |
179 | | title | `String` | `titleCase(key)` | The title displayed in the header |
180 | | searchable | `Bool` | `true` | Whether to allow searching rows by this column field |
181 | | sortable | `Bool` | `true` | Whether to allow sorting the data by this column field |
182 | | editable | `Bool` | `true` | Whether the column is editable by the user |
183 | | collapsible | `Bool` | `false` | Whether the column is collapsible (expand and collapse) |
184 | | type | `String` | `string` | Data type of `key`. Allowed values: `string`, `number` |
185 | | compareFunction | `Function` | - | Custom function provided by the user to sort the column |
186 | | searchFunction | `Function` | - | Custom function provided by the user to search the column |
187 | | index | `Number` | 1000 | Lower values shift the column to the left of the table |
188 | | component | `String`, `Object` | - | Custom Vue component to render inside table cell |
189 | | componentProps | `Object` | - | Props to pass to the custom component |
190 |
191 | If `columns` is not defined, then `columnKeys` must be defined and it will be
192 | mapped to a `columns` array with the default parameters. Example:
193 |
194 | ```javascript
195 | // we can define the columns
196 | config = {
197 | data: users,
198 | columns: [
199 | {
200 | key: "name",
201 | },
202 | {
203 | key: "email",
204 | title: "Email Address",
205 | sortable: false,
206 | },
207 | {
208 | key: "phone",
209 | sortable: false,
210 | searchable: false,
211 | index: 1, // smaller indexes means the column is shift to the left
212 | },
213 | {
214 | key: "permissions",
215 |
216 | /* custom function sort users by which user has more permissions */
217 | compareFunction: function(a, b) {
218 | // permissions is an array
219 | return a.permissions.length - b.permissions.length;
220 | },
221 |
222 | /* custom function to allow searching the permission array */
223 | searchFunction: function(search, data) {
224 | return data.permissions.some(permission => permission.includes(search))
225 | },
226 |
227 | searchable: true,
228 |
229 | /* custom component to display the permissions */
230 | component: UserPermissionList,
231 | }
232 | ]
233 | }
234 |
235 | // or use columnKeys shortcut
236 | config = {
237 | data: user,
238 | columnKeys: ["name", "email", "registered_at", "last_access_at"]
239 | },
240 |
241 | // which will take the default column and map the array into this
242 | [
243 | {
244 | key: "name",
245 | title: "Name",
246 | sortable: true,
247 | searchable: true,
248 | index: 1000
249 | },
250 | {
251 | key: "email",
252 | title: "Email",
253 | sortable: true,
254 | searchable: true,
255 | index: 1000
256 | },
257 | {
258 | key: "registered_at",
259 | title: "Registered At",
260 | sortable: true,
261 | searchable: true,
262 | index: 1000
263 | },
264 | {
265 | key: "last_access_at",
266 | title: "Last Access At",
267 | sortable: true,
268 | searchable: true,
269 | index: 1000
270 | },
271 | ]
272 | ```
273 |
274 | #### Custom Cell Component
275 |
276 | Custom cell components must have a `data` property to receive the data of the current
277 | row for the component to display it.
278 |
279 | In the previous code snippet, we used our custom component `UserPermissionList`.
280 | Below is a sample of that custom component.
281 |
282 | ```vue
283 |
284 |
285 | List of permissions for the user {{ data.name }} :
286 |
287 |
288 | {{ permission }}
289 |
290 |
291 |
292 |
293 |
304 | ```
305 |
306 | To handle events triggered by a custom component (such as clicking a button in a
307 | component), the component should emit an event called `userEvent` and pass an
308 | arbitrary payload to it. The event will be propagated upwards by `VueDataTable`,
309 | which will also emit an event called `userEvent` whose payload is the same as
310 | the one emitted by the custom component. For example:
311 |
312 | ```vue
313 |
314 |
315 |
316 |
338 | ```
339 |
340 | When the users clicks the checkbox, it will emit an `userEvent` event, which can
341 | be accessed from the `VueDataTable`. Here is an continuation of the previous
342 | example.
343 |
344 | ```vue
345 |
346 |
347 |
DASHBOARD
348 |
351 |
355 |
356 |
357 |
383 | ```
384 |
385 | In the code snippet above, when the user checks the checkbox rendered by the
386 | custom component `CheckboxCell`, it will emit an event that is handled by the
387 | method `handleEvent`. This method will add/remove the `id` of the row to/from
388 | the `selectedRows` array. When the user clicks the "dangerous delete button", it
389 | will deleted the selected rows from the table (on the client side only).
390 |
391 | #### Action Buttons
392 |
393 | `VueDataTable` provides a component called `VdtActionButtons`, which can be used
394 | to display buttons for common CRUD action such as viewing, editing, deleting.
395 |
396 | Here is an example with all buttons (view, edit, delete) in one column:
397 |
398 | ```vue
399 |
400 |
401 |
DASHBOARD
402 |
403 |
404 |
405 |
427 | ```
428 |
429 | Another example, this time one button per column:
430 |
431 | ```vue
432 |
433 |
434 |
DASHBOARD
435 |
436 |
437 |
438 |
474 | ```
475 |
476 | When an user click an action button, `VueDataTable` will emit an event whose
477 | payload is an object with two fields: `action` and `data`. The `action` is the
478 | name of the action (view, edit, delete) and `data` is the data of the row.
479 |
480 | Check out the demo to see a real working example of using action buttons.
481 |
482 | #### Editable cells
483 |
484 | It is possible to make a column editable by settings `editable` to true in the
485 | column definition.
486 |
487 | ```javascript
488 | columns: {
489 | [ 'key': name, editable: true],
490 | [ 'key': email, editable: true],
491 | [ 'key': phone, editable: true],
492 | // ...
493 | }
494 | ```
495 |
496 | This will make `VueDataTable` display an `edit` button on the right side of the
497 | cell's text. When the user clicks the button, it will show an input, so the user
498 | can enter a new value for the cell. The user can cancel the editing or confirm.
499 | If the user confirms editing, `VueDataTable` will emit a `userEvent` whose
500 | payload looks like the following:
501 |
502 | ```javascript
503 | {
504 | action: 'updateCell',
505 | key: '',
506 | data: '',
507 | value: '',
508 | }
509 | ```
510 |
511 | Where `key` is the key of the column (if user edits the `name` column, the `key`
512 | will be `name`), the `data` is the object of the row which was edit (an example:
513 | `{ id: 100, name: 'joe', email: 'joe@email.test' }`), and `value` is the value
514 | inserted by the user (such as `Joe Doe`).
515 |
516 | It is up to the developer to handle the event to update the row by, for example,
517 | sending an AJAX request to the API, then updating the `data` array on the client
518 | side. Here is an example of how to update the data array on the client side:
519 |
520 | ```vue
521 |
522 |
523 |
524 |
552 | ```
553 |
554 | #### Selectable rows
555 |
556 | `VueDataTable` provides the built-in `vdt-cell-selectable` component to select
557 | table rows.
558 |
559 | ```javascript
560 | const props = {
561 | columns = [
562 | {
563 | title: "",
564 | component: "vdt-cell-selectable" // <-- ADD THIS
565 | },
566 | { key: "name" },
567 | { key: "email" },
568 | /* ... */
569 | ],
570 | vKey = "id",
571 | };
572 | const data = [
573 | { id: 1, name: "joe", email: "joe@example.com" },
574 | /* ... */
575 | ]
576 | ```
577 |
578 | When the user toggles the checkbox, `VueDataTable` emits an event called
579 | `userEvent` with the following payload:
580 |
581 | ```javascript
582 | {
583 | action: "select",
584 | selected: true || false, // this is the current value of the checkbox
585 | data: {}, // this is the current row (example, a user from an users array)
586 | }
587 | ```
588 |
589 | You can have a reactive variable to keep track of selected items:
590 |
591 | ```javascript
592 | const selected = ref([]);
593 |
594 | const handleSelect(payload) {
595 | const item = payload.data;
596 | if (payload.selected) {
597 | selected.value.push(item);
598 | } else {
599 | selected.value = selected.value.filter((x) => x.id !== item.id);
600 | }
601 | }
602 | ```
603 |
604 | You can use this variable to perform bulk operations, such as mass deletion or
605 | mass edition.
606 |
607 | ### Text
608 |
609 | Currently, `VueDataTable` has support for the following languages: English (en),
610 | Brazilian Portuguese (pt-br), and Spanish(es). The `lang` prop specifies in
611 | which language to display the text in our table.
612 |
613 | If we want to add a custom text (maybe because there is no language support or
614 | because we want something else), we have to set it in the `text` prop.
615 |
616 | The following table shows the texts we can customize and their default values
617 | for the English language.
618 |
619 | | key | default |
620 | | --- | --- |
621 | | perPageText | "Show :entries entries" |
622 | | perPageAllText | "ALL" |
623 | | infoText | "Showing :first to :last of :total entries" |
624 | | infoAllText | "Showing all entries" |
625 | | infoFilteredText | "Showing :first to :last of :filtered (filtered from :total entries)" |
626 | | nextButtonText | "Next" |
627 | | previousButtonText | "Previous" |
628 | | paginationSearchText | "Go to page" |
629 | | paginationSearchButtonText | "GO" |
630 | | searchText | "search:" |
631 | | downloadText | "export as:" |
632 | | downloadButtonText | "DOWNLOAD" |
633 | | emptyTableText | "No matching records found" |
634 |
635 | **Note**: Notice that the placeholders `:first`, `:last`, `:total`, and
636 | `:filtered` will be automatically replaced with the proper numbers.
637 |
638 | Example code:
639 |
640 | ```javascript
641 | parameters() {
642 | return {
643 | data: [/**/],
644 | columns: [/**/],
645 | text: {
646 | PerPageText: "Number of users per page :entries",
647 | infoText: "Displaying :first to :last of :total users",
648 | emptyTableText: "No users found :(",
649 | }
650 | }
651 | }
652 | ```
653 |
654 | #### Adding Language
655 |
656 | If your language is not yet supported, you can add a new language and use it in
657 | any `VueDataTable` instance as follow:
658 |
659 | ```javascript
660 | import { languageServiceProvider } from "@uwlajs/vue-data-table";
661 |
662 | const loremIpsumLanguage = {
663 | perPageText: "lorem ipsum",
664 | nextButtonText: "labore ipsum",
665 | /* more ... */
666 | };
667 |
668 | languageServiceProvider.setLang("lorem", loremIpsumLanguage)
669 | ```
670 |
671 | You can also change any default text for an existing language and that will
672 | reflect the changes globally. For example:
673 |
674 | ```javascript
675 | // the default text for the download button in the export component is "export as"
676 | // we may want change that to "download as"
677 | languageServiceProvider.setLangText("en", "downloadText", "download as:")
678 | ```
679 |
680 |
681 | ### Async data
682 |
683 | If you do not want to fetch all data at once and pass it to `VueDataTable` via
684 | the `data` prop, you can do so by defining:
685 |
686 | - `fetchUrl`: initial endpoint for the first ajax request to fetch data
687 | - `fetchCallback`: async function which takes an URL and returns a response
688 | following Laravel's pagination API.
689 |
690 | Here is a sample `fetchCallback`:
691 |
692 | ```vue
693 |
694 |
Users
695 |
696 |
697 |
707 | ```
708 |
709 | The example above uses the browser's built-in `fetch`, but you can also use
710 | `axios` or Nuxt's `$fetch` under the hood. Just make sure the response returned
711 | by the callback matches the following.
712 |
713 | The response from the `fetchCallback` should look like this:
714 |
715 | ```jsonc
716 | {
717 | "data": [
718 | { "id": 1, "name": "Miss Juliet Heidenreich", "email": "alvera13@example.org"},
719 | { "id": 2, "name": "Heloise Boehm", "email": "joany.feil@example.net"},
720 | { "id": 3, "name": "Antwon Collins", "email": "xhills@example.com},
721 | /* ... */
722 | ],
723 | "current_page": 1,
724 | "first_page_url": "http://app.test/api/users?page=1",
725 | "from": 1,
726 | "last_page": 23,
727 | "last_page_url": "http://app.test/api/users?page=23",
728 | "links": [
729 | { "url": null, "label": "« Previous", "active": false },
730 | { "url": "http://app.test/api/users?page=1", "label": "1", "active": true },
731 | { "url": "http://app.test/api/users?page=2", "label": "2", "active": false },
732 | { "url": "http://app.test/api/users?page=3", "label": "3", "active": false },
733 | /* ... */
734 | { "url": "http://app.test/api/users?page=23", "label": "23", "active": false },
735 | { "url": "http://app.test/api/users?page=2", "label": "Next »", "active": false }
736 | ],
737 | "next_page_url": "http://app.test/api/users?page=2",
738 | "path": "http://app.test/api/users",
739 | "per_page": 15,
740 | "prev_page_url": null,
741 | "to": 15,
742 | "total": 340
743 | }
744 | ```
745 |
746 | Here is how you do so in Laravel:
747 |
748 | ```php
749 | allowedSorts(['name', 'email'])
776 | ->allowedFilters(['name', 'email'])
777 | ->paginate();
778 | });
779 | ```
780 |
781 | The endpoints look like this:
782 |
783 | - `http://app.test/api/users?page=1&filter[name]=foo`
784 | - `http://app.test/api/users?page31&sort=job,-email`
785 | - `http://app.test/api/users?page=1&sort=email&filter[email]=joe&filter=[name]=joe`
786 |
787 | You do **not** need to worry about the URLs if you are using Spatie's Laravel Query Bulder,
788 | because `VueDataTable` follows their endpoint standard and automatically generates the urls.
789 |
790 | If you do not use their package, then you should parse the `url` variable inside
791 | the `fetchCallback`, and modify the url. For example, your javascript code should modify:
792 |
793 | `http://app.test/api/users?page=4&filter[name]=foo --> http://app.test/api/users?page=4&search=foo`.
794 |
795 | Keep in mind that, by default, Spatie's Query Builder apply AND logic for all
796 | filters. That means if you have `&filter[name]=Ana&filter[email]=Ana`, then
797 | you will only get results that both `name` and `email` fields match Ana. If
798 | `name` matches Ana but not the `email` column, then this row would not appear.
799 |
800 | Here is how you can implement `OR` logic using their package:
801 |
802 | ```php
803 | allowedSorts(['name', 'email'])
815 | ->allowedFilters([
816 | AllowedFilter::custom('name', new FilterOrWhere),
817 | AllowedFilter::custom('email', new FilterOrWhere)
818 | ])
819 | ->paginate();
820 | });
821 |
822 | // app/Http/Filters/FilterOrWhere.php
823 | namespace App\Http\Filters;
824 |
825 | use Spatie\QueryBuilder\Filters\Filter;
826 | use Illuminate\Database\Eloquent\Builder;
827 |
828 | class FilterOrWhere implements Filter
829 | {
830 | public function __invoke(Builder $query, $value, string $property)
831 | {
832 | $query->orWhere($property, 'LIKE', '%' . $value . '%');
833 | }
834 | }
835 | ```
836 |
837 | ### Layout
838 |
839 | `VueDataTable` uses CSS's grid display to specify the position of its components
840 | (search filter, pagination, entries info, per page options, download button).
841 |
842 | **We can specify the position of the components by including our custom CSS/SCSS
843 | and overriding the defaults.**
844 |
845 | By default, this is how `VueDataTable` displays the components:
846 |
847 | ```scss
848 | .data-table {
849 | display: grid;
850 | width: 100%;
851 | grid-template-columns: 25% 25% 25% 25%;
852 | &> div {
853 | margin-top: 1rem;
854 | max-width: 100%;
855 | }
856 | & > .data-table-search-filter, .data-table-pagination, .data-table-export-data {
857 | margin-left: auto
858 | }
859 | @media (min-width: 1401px) {
860 | grid-template-areas:
861 | "perPage search search search"
862 | "table table table table"
863 | "info pagination pagination download";
864 | }
865 | @media (min-width: 1051px) AND (max-width: 1400px) {
866 | grid-template-areas:
867 | "perPage search search search"
868 | "table table table table"
869 | "info pagination pagination pagination"
870 | ". . download download";
871 | }
872 | @media (min-width: 851px) AND (max-width: 1050px) {
873 | grid-template-areas:
874 | "perPage search search search"
875 | "table table table table"
876 | "pagination pagination pagination pagination"
877 | "info info download download";
878 | }
879 | @media (max-width: 800px) {
880 | & > .data-table-pagination {
881 | flex-wrap: wrap;
882 | }
883 | }
884 | @media (min-width: 651px) AND (max-width: 850px) {
885 | grid-template-areas:
886 | "perPage search search search"
887 | "table table table table"
888 | "pagination pagination pagination pagination"
889 | "info info info info"
890 | "download download download download";
891 | }
892 | @media (max-width: 650px) {
893 | grid-template-areas:
894 | "search search search search"
895 | "perPage perPage perPage perPage "
896 | "table table table table"
897 | "pagination pagination pagination pagination"
898 | "info info info info"
899 | "download download download download";
900 | & > .data-table-per-page {
901 | margin-left: auto
902 | }
903 | }
904 | }
905 | ```
906 |
907 | Feel free to copy the styles above, modify it, and then set the position of the
908 | components as you want.
909 |
910 | ### Custom Components
911 |
912 | Besides a custom component for each column, you provide custom components for
913 | the table's footer, the column's `sorting icon` (the icon displayed if the
914 | columns is sorted), and the column's `sorting index` (the index of the current
915 | column if it is being sorted and multi column sorting is enabled).
916 |
917 | #### Footer
918 |
919 | The property `footerComponent` sets the component to render the table's footer.
920 | The component can be either the component `Object`, or a `String` equals to the
921 | name of the registered component.
922 |
923 | The `footerComponent` must be a `` HTML element and it must have the
924 | properties `data`, `dataDisplayed`, `dataFiltered`. If the component does not
925 | specify those properties in `props`, Vue will probably think they are some
926 | custom HTML attribute and their values will be show as HTML attributes, which is
927 | really messy.
928 |
929 | The property `data` correspond to all data passed to `VueDataTable`. The
930 | `dataDisplayed` corresponds to all data that is currently visible on the table.
931 | The `dataFiltered` corresponds to all data that was filtered by a search query.
932 | These properties can be used to perform common operations such as calculating
933 | the sum of the values of the total rows of a certain column.
934 |
935 | Suppose we have a table that of fruits. The `data` is an array of objects whose
936 | properties are `name`, `price`, and `amount`. We can provide a custom footer to
937 | show the total amount of fruits bought and the total price.
938 |
939 | The footer component would be something like:
940 |
941 | ```vue
942 |
943 |
944 |
Total
945 |
946 |
{{ totalAmount }}
947 |
{{ totalPrice }}
948 |
949 |
950 |
974 | ```
975 |
976 | And we pass this component as follow:
977 |
978 | ```vue
979 |
980 |
981 |
982 |
998 | ```
999 |
1000 | Alternately, you can register the component and pass a string:
1001 |
1002 | ```javascript
1003 | /* early on */
1004 | import TableFooter from './TableFooter.vue'
1005 | Vue.component("table-footer", TableFooter)
1006 |
1007 | /* later on */
1008 | footerComponent: "table-footer"
1009 | ```
1010 |
1011 | #### Sort Icon
1012 |
1013 | By default, `VueDataTable` will display arrows to indicate the sorting direction
1014 | when sorting a column. The `SortingIcon` component is wrapped in a `th` element.
1015 | The `th` element has a `data-sorting` attribute that may be `asc` or `desc`
1016 | only. Based on this value, we display an `arrow_up` or an `arrow_down` icon
1017 | using `CSS` rules.
1018 |
1019 | ```vue
1020 |
1021 |
1022 |
1023 |
1039 | ```
1040 |
1041 | **Note**: Some code was omitted to keep it clean.
1042 |
1043 | If we want to add our custom icons for this, then we can register our component,
1044 | like so:
1045 |
1046 | ```javascript
1047 | import SortingIcon from "./path/to/SortIcon.vue";
1048 |
1049 | export default {
1050 | computed: {
1051 | bindings() {
1052 | return {
1053 | SortingIconComponent: SortingIcon,
1054 | data: [],
1055 | /**/
1056 | };
1057 | }
1058 | }
1059 | }
1060 | ```
1061 |
1062 | #### Sorting Index Icon
1063 |
1064 | When sorting multiple columns, `VueDataTable` will display an icon with a index
1065 | indicating which column has the priority in the sorting process.
1066 |
1067 | ```vue
1068 |
1069 |
1070 | {{ index }}
1071 |
1072 |
1073 | ```
1074 |
1075 | If we want to add our own component for this, we can register it just like we
1076 | did before.
1077 |
1078 | ```javascript
1079 | import SortingIndex from "./path/to/SortingIndex.vue";
1080 |
1081 | export default {
1082 | computed: {
1083 | bindings() {
1084 | return {
1085 | SortingIndexComponent: SortingIndex,
1086 | data: [],
1087 | /**/
1088 | };
1089 | }
1090 | }
1091 | };
1092 | ```
1093 |
1094 | In our `SortingIndex` component, we must have a `index` property, which
1095 | correspondent to the index of the column in the sorting process.
1096 |
1097 | ```javascript
1098 | export default {
1099 | name: "SortingIndex",
1100 | props: {
1101 | index: {
1102 | type: Number,
1103 | required: true
1104 | }
1105 | }
1106 | };
1107 | ```
1108 |
1109 | ## ROADMAP
1110 |
1111 | - [x] Support for Vue3
1112 | - [x] Laravel integration
1113 | - [ ] Support for SSR
1114 | - [ ] String notation for defining columns
1115 |
1116 | ## LICENSE
1117 |
1118 | MIT
1119 |
1120 | ## CONTRIBUTING
1121 |
1122 | Pull requests are very welcome.
--------------------------------------------------------------------------------