Skip to content

Commit dacf0c1

Browse files
feat: sort, pagination, spinner and checkbox added to table component (#5)
Co-authored-by: jamesgeorge007 <[email protected]>
1 parent 4c2ea5b commit dacf0c1

File tree

2 files changed

+414
-80
lines changed

2 files changed

+414
-80
lines changed

src/components/smart/Table.vue

Lines changed: 264 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,288 @@
11
<template>
2-
<div class="overflow-auto border shadow-md rounded-md border-dividerDark">
3-
<table class="w-full">
4-
<thead>
5-
<slot name="head">
2+
<div class="flex flex-1 flex-col">
3+
<div v-if="pagination" class="mb-3 flex items-center justify-end">
4+
<HoppButtonSecondary
5+
outline
6+
filled
7+
:icon="IconLeft"
8+
:disabled="page === 1"
9+
@click="changePage(PageDirection.Previous)"
10+
/>
11+
12+
<span class="flex h-full w-10 items-center justify-center">{{
13+
page
14+
}}</span>
15+
16+
<HoppButtonSecondary
17+
outline
18+
filled
19+
:icon="IconRight"
20+
:disabled="page === pagination.totalPages"
21+
@click="changePage(PageDirection.Next)"
22+
/>
23+
</div>
24+
25+
<div class="overflow-auto rounded-md border border-dividerDark shadow-md">
26+
<!-- An Extension Slot to extend the table functionality such as search -->
27+
<slot name="extension"></slot>
28+
29+
<table class="w-full table-fixed">
30+
<thead>
631
<tr
7-
class="text-sm text-left border-b border-dividerDark bg-primaryLight text-secondary"
32+
class="border-b border-dividerDark bg-primaryLight text-left text-sm text-secondary"
833
>
9-
<th v-for="th in headings" scope="col" class="px-6 py-3">
10-
{{ th.label ?? th.key }}
34+
<th v-if="selectedRows" class="w-24">
35+
<input
36+
ref="selectAllCheckbox"
37+
type="checkbox"
38+
:checked="areAllRowsSelected"
39+
:disabled="loading"
40+
class="flex h-full w-full items-center justify-center"
41+
@click.stop="toggleAllRows"
42+
/>
1143
</th>
44+
<slot name="head">
45+
<th
46+
v-for="th in headings"
47+
:key="th.key"
48+
scope="col"
49+
class="px-6 py-3"
50+
>
51+
{{ th.label ?? th.key }}
52+
</th>
53+
</slot>
1254
</tr>
13-
</slot>
14-
</thead>
55+
</thead>
1556

16-
<tbody class="divide-y divide-divider">
17-
<!-- We are using slot props for future proofing so that in future, we can implement features like filtering -->
18-
<slot name="body" :list="list">
19-
<tr
20-
v-for="(rowData, rowIndex) in list"
21-
:key="rowIndex"
22-
class="rounded-xl text-secondaryDark hover:cursor-pointer hover:bg-divider"
23-
:class="{ 'divide-x divide-divider': showYBorder }"
24-
>
25-
<td
26-
v-for="cellHeading in headings"
27-
:key="cellHeading.key"
28-
@click="!cellHeading.preventClick && onRowClicked(rowData)"
29-
class="max-w-[10rem] py-1 pl-6"
30-
>
31-
<!-- Dynamic column slot -->
32-
<slot :name="cellHeading.key" :item="rowData">
33-
<!-- Generic implementation of the column -->
34-
<div class="flex flex-col truncate">
35-
<span class="truncate">
36-
{{ rowData[cellHeading.key] ?? "-" }}
37-
</span>
57+
<tbody class="divide-y divide-divider">
58+
<tr v-if="loading">
59+
<slot name="loading-state">
60+
<td :colspan="columnSpan">
61+
<div class="mx-auto my-3 h-5 w-5 text-center">
62+
<HoppSmartSpinner />
3863
</div>
39-
</slot>
40-
</td>
64+
</td>
65+
</slot>
66+
</tr>
67+
68+
<tr v-else-if="!list.length">
69+
<slot name="empty-state">
70+
<td :colspan="columnSpan" class="py-3 text-center">
71+
<p>No data available</p>
72+
</td>
73+
</slot>
4174
</tr>
42-
</slot>
43-
</tbody>
44-
</table>
75+
76+
<template v-else>
77+
<tr
78+
v-for="(rowData, rowIndex) in workingList"
79+
:key="rowIndex"
80+
class="rounded-xl text-secondaryDark hover:cursor-pointer hover:bg-divider"
81+
:class="{ 'divide-x divide-divider': showYBorder }"
82+
@click="onRowClicked(rowData)"
83+
>
84+
<td v-if="selectedRows">
85+
<input
86+
type="checkbox"
87+
:checked="isRowSelected(rowData)"
88+
class="flex h-full w-full items-center justify-center"
89+
@click.stop="toggleRow(rowData)"
90+
/>
91+
</td>
92+
<slot name="body" :row="rowData">
93+
<td
94+
v-for="cellHeading in headings"
95+
:key="cellHeading.key"
96+
class="px-4 py-2"
97+
@click="!cellHeading.preventClick && onRowClicked(rowData)"
98+
>
99+
<!-- Dynamic column slot -->
100+
<slot :name="cellHeading.key" :item="rowData">
101+
<!-- Generic implementation of the column -->
102+
<div class="flex flex-col truncate">
103+
<span class="truncate">
104+
{{ rowData[cellHeading.key] ?? "-" }}
105+
</span>
106+
</div>
107+
</slot>
108+
</td>
109+
</slot>
110+
</tr>
111+
</template>
112+
</tbody>
113+
</table>
114+
</div>
45115
</div>
46116
</template>
47117

48-
<script lang="ts" setup generic="Item extends Record<string, unknown>">
118+
<script lang="ts" setup>
119+
import { useVModel } from "@vueuse/core"
120+
import { isEqual } from "lodash-es"
121+
import { computed, ref, watch } from "vue"
122+
123+
import IconLeft from "~icons/lucide/arrow-left"
124+
import IconRight from "~icons/lucide/arrow-right"
125+
126+
import { HoppSmartSpinner } from ".."
127+
import { HoppButtonSecondary } from "../button"
128+
49129
export type CellHeading = {
50130
key: string
51131
label?: string
52132
preventClick?: boolean
53133
}
54134
55-
defineProps<{
56-
/** Whether to show the vertical border between columns */
57-
showYBorder?: boolean
58-
/** The list of items to be displayed in the table */
59-
list?: Item[]
60-
/** The headings of the table */
61-
headings?: CellHeading[]
62-
}>()
135+
export type Item = Record<string, unknown>
136+
137+
const props = withDefaults(
138+
defineProps<{
139+
/** Whether to show the vertical border between columns */
140+
showYBorder?: boolean
141+
/** The list of items to be displayed in the table */
142+
list: Item[]
143+
/** The headings of the table */
144+
headings?: CellHeading[]
145+
146+
selectedRows?: Item[]
147+
/** Whether to enable sorting */
148+
sort?: {
149+
/** The key to sort the list by */
150+
key: string
151+
direction: Direction
152+
}
153+
154+
/** Whether to enable pagination */
155+
pagination?: {
156+
totalPages: number
157+
}
158+
159+
/** Whether to show a loading spinner */
160+
loading?: boolean
161+
}>(),
162+
{
163+
showYBorder: false,
164+
sort: undefined,
165+
selectedRows: undefined,
166+
loading: false,
167+
},
168+
)
63169
64170
const emit = defineEmits<{
65171
(event: "onRowClicked", item: Item): void
172+
(event: "update:list", list: Item[]): void
173+
(event: "update:selectedRows", selectedRows: Item[]): void
174+
(event: "pageNumber", page: number): void
66175
}>()
67176
177+
// Pagination functionality
178+
const page = ref(1)
179+
180+
enum PageDirection {
181+
Previous,
182+
Next,
183+
}
184+
185+
const changePage = (direction: PageDirection) => {
186+
const isPrevious = direction === PageDirection.Previous
187+
188+
const isValidPreviousAction = isPrevious && page.value > 1
189+
const isValidNextAction =
190+
!isPrevious && page.value < props.pagination!.totalPages
191+
192+
if (isValidNextAction || isValidPreviousAction) {
193+
page.value += isPrevious ? -1 : 1
194+
195+
emit("pageNumber", page.value)
196+
}
197+
}
198+
199+
// The working version of the list that is used to perform operations upon
200+
const workingList = useVModel(props, "list", emit)
201+
202+
// Checkbox functionality
203+
const selectedRows = useVModel(props, "selectedRows", emit)
204+
205+
watch(workingList.value, (updatedList) => {
206+
if (props.selectedRows) {
207+
updatedList = updatedList.map((item) => ({
208+
...item,
209+
selected: false,
210+
}))
211+
}
212+
})
213+
68214
const onRowClicked = (item: Item) => emit("onRowClicked", item)
215+
216+
const isRowSelected = (item: Item) => {
217+
const { selected, ...data } = item
218+
return selectedRows.value?.some((row) => isEqual(row, data))
219+
}
220+
221+
const toggleRow = (item: Item) => {
222+
item.selected = !item.selected
223+
const { selected, ...data } = item
224+
225+
const index = selectedRows.value?.findIndex((row) => isEqual(row, data)) ?? -1
226+
227+
if (item.selected && !isRowSelected(data)) {
228+
selectedRows.value!.push(data)
229+
} else if (index !== -1) {
230+
selectedRows.value?.splice(index, 1)
231+
}
232+
}
233+
234+
const selectAllCheckbox = ref<HTMLInputElement | null>(null)
235+
236+
const toggleAllRows = () => {
237+
const isChecked = selectAllCheckbox.value?.checked
238+
workingList.value.forEach((item) => {
239+
item.selected = isChecked
240+
const { selected, ...data } = item
241+
if (isChecked) {
242+
if (!isRowSelected(item)) {
243+
selectedRows.value!.push(data)
244+
}
245+
return
246+
}
247+
const index =
248+
selectedRows.value?.findIndex((row) => isEqual(row, data)) ?? -1
249+
selectedRows.value!.splice(index, 1)
250+
})
251+
}
252+
253+
const areAllRowsSelected = computed(() => {
254+
if (workingList.value.length === 0 || selectedRows.value?.length === 0)
255+
return false
256+
return workingList.value.every((item) => {
257+
const { selected, ...data } = item
258+
return selectedRows.value?.some((row) => isEqual(row, data))
259+
})
260+
})
261+
262+
// Sort List by key and direction which can set to ascending or descending
263+
export type Direction = "ascending" | "descending"
264+
265+
const sortList = (key: string, direction: Direction) => {
266+
workingList.value.sort((a, b) => {
267+
const valueA = a[key] as string
268+
const valueB = b[key] as string
269+
return direction === "ascending"
270+
? valueA.localeCompare(valueB)
271+
: valueB.localeCompare(valueA)
272+
})
273+
}
274+
275+
watch(
276+
() => props.sort?.direction,
277+
() => {
278+
if (props.sort) {
279+
sortList(props.sort.key, props.sort.direction)
280+
}
281+
},
282+
{ immediate: true },
283+
)
284+
285+
const columnSpan = computed(
286+
() => (props.headings?.length ?? 0) + (props.selectedRows ? 1 : 0),
287+
)
69288
</script>

0 commit comments

Comments
 (0)