Skip to content

Commit f2ebbb9

Browse files
authored
Simple pagination for DataTable (#98)
* Load more button and event for tables * Rework loading and add async button
1 parent f19696e commit f2ebbb9

File tree

8 files changed

+234
-50
lines changed

8 files changed

+234
-50
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `DataTable`: Props `next` and `fa`
13+
1014
## [2.17.0] - 2024-08-12
1115

1216
### Added

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ A relatively simple table component to show a list of data.
223223

224224
- `columns` (object, required): The columns to show in the table.
225225
- `data` (array, required): An array of objects containing the data to show.
226+
- `next` (function): Indicates whether more data is available to be loaded/shown and how. Shows a button to load more data into the table and executes the given (async) function. Defaults to `null` (i.e. no more data available).
227+
- `fa` (boolean): Whether to use Font Awesome icons or not. Defaults to `false`.
226228

227229
**Slots:**
228230

components/DataTable.vue

+27-7
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,18 @@
55
<slot name="toolbar"></slot>
66
</div>
77
<div class="filter" v-if="hasData">
8-
<SearchBox v-model="filterValue" :compact="true" />
8+
<SearchBox v-model="filterValue" :placeholder="searchPlaceholder" :compact="true" />
99
</div>
1010
</div>
1111
<table v-if="hasData">
1212
<thead>
1313
<tr>
14-
<th v-for="(col, id) in columns" v-show="!col.hide" :key="col.name" :class="thClasses(id)" @click="enableSort(id)" :title="thTitle(id)">{{ col.name }}</th>
14+
<th v-for="(col, id) in columns" v-show="!col.hide" :key="col.name" :width="col.width" :class="thClasses(id)" @click="enableSort(id)" :title="thTitle(id)">{{ col.name }}</th>
1515
</tr>
1616
</thead>
1717
<tbody>
1818
<tr v-for="(row, i) in view" :key="i">
19-
<td v-for="(col, id) in columns" v-show="!col.hide" :key="`${col.name}_${i}`"
19+
<td v-for="(col, id) in columns" v-show="!col.hide" :key="`${col.name}_${id}`"
2020
:class="[id, {'edit': canEdit(col)}]"
2121
:title="canEdit(col) ? 'Double-click to change the value' : false"
2222
@dblclick="onDblClick($event, row, col, id)"
@@ -37,6 +37,7 @@
3737
</tbody>
3838
</table>
3939
<div class="no-data" v-else>{{ noDataMessage }}</div>
40+
<AsyncButton v-if="hasMore" :fa="fa" icon="fas fa-sync" class="has-more-button" :fn="next">Load more...</AsyncButton>
4041
</div>
4142
</template>
4243

@@ -47,6 +48,7 @@ import { DataTypes, Formatters } from '@radiantearth/stac-fields';
4748
export default {
4849
name: 'DataTable',
4950
components: {
51+
AsyncButton: () => import('./internal/AsyncButton.vue'),
5052
SearchBox: () => import('./SearchBox.vue')
5153
},
5254
props: {
@@ -57,6 +59,15 @@ export default {
5759
data: {
5860
type: Array,
5961
default: () => ([])
62+
},
63+
next: {
64+
type: Function,
65+
default: null
66+
},
67+
fa: {
68+
// Whether to use Font Awesome icons or not
69+
type: Boolean,
70+
default: false
6071
}
6172
},
6273
data() {
@@ -85,6 +96,9 @@ export default {
8596
columns: {
8697
immediate: true,
8798
handler() {
99+
if (this.hasMore) {
100+
return;
101+
}
88102
for(let id in this.columns) {
89103
let direction = this.columns[id].sort;
90104
if (['asc', 'desc'].includes(direction)) {
@@ -96,6 +110,9 @@ export default {
96110
}
97111
},
98112
computed: {
113+
hasMore() {
114+
return typeof this.next === 'function';
115+
},
99116
columnCount() {
100117
return Object.keys(this.columns).length;
101118
},
@@ -104,6 +121,9 @@ export default {
104121
},
105122
hasFilter() {
106123
return (typeof this.filterValue === 'string' && this.filterValue.length > 0) ? true : false;
124+
},
125+
searchPlaceholder() {
126+
return this.hasMore ? `Search through subset of loaded data...` : `Search...`;
107127
}
108128
},
109129
beforeCreate() {
@@ -196,7 +216,7 @@ export default {
196216
thClasses(id) {
197217
let col = this.columns[id];
198218
let classes = [id];
199-
if (col.sort !== false) {
219+
if (!this.hasMore && col.sort !== false) {
200220
classes.push('sortable');
201221
if (this.sortState.id === id) {
202222
classes.push('sort-' + this.sortState.direction);
@@ -206,7 +226,7 @@ export default {
206226
},
207227
thTitle(id) {
208228
let col = this.columns[id];
209-
if (col.sort !== false) {
229+
if (!this.hasMore && col.sort !== false) {
210230
if (this.sortState.id === id && this.sortState.direction === 'asc') {
211231
return "Click to sort column in descending order";
212232
}
@@ -217,7 +237,7 @@ export default {
217237
return null;
218238
},
219239
enableSort(id, direction = null) {
220-
if (this.columns[id].sort === false) {
240+
if (this.hasMore || this.columns[id].sort === false) {
221241
return;
222242
}
223243
if (direction === null) {
@@ -358,7 +378,7 @@ export default {
358378
text-align: right;
359379
padding-left: 1em;
360380
min-width: 4em;
361-
max-width: 20em;
381+
max-width: 30em;
362382
.edit {
363383
cursor: pointer;
364384
}

components/FederationMissingNotice.vue

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
<template>
22
<section v-if="services" class="vue-component message-block federation federation-backends">
3-
<button v-if="retry" type="button" class="retry" @click="retry">
4-
<slot name="button-text">Retry</slot>
5-
</button>
3+
<AsyncButton v-if="retry" confirm class="retry" :fn="retry">Retry</AsyncButton>
64
<strong class="header">Incomplete</strong>
75
<p>
86
The following list is incomplete as at least one of the services in the federation is currently not available.
@@ -17,6 +15,9 @@ import Utils from '../utils';
1715
1816
export default {
1917
name: 'FederationMissingNotice',
18+
components: {
19+
AsyncButton: () => import('./internal/AsyncButton.vue')
20+
},
2021
mixins: [
2122
FederationMixin
2223
],

components/internal/AsyncButton.vue

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<template>
2+
<button type="button" v-show="fn" :title="title" :disabled="disabled" class="async-button" :class="{awesome: fa}" @click="update">
3+
<span class="button-content">
4+
<span v-if="loading" class="icon loading">
5+
<i v-if="fa" :class="loadingClasses"></i>
6+
<LoadingIcon rotate v-else />
7+
</span>
8+
<span v-else-if="asyncState === true" class="icon success">
9+
<i v-if="fa" class="fas fa-check"></i>
10+
<span v-else>✔️</span>
11+
</span>
12+
<span v-else-if="asyncState === false" class="icon error">
13+
<i v-if="fa" class="fas fa-times"></i>
14+
<span v-else>❌</span>
15+
</span>
16+
<span v-else class="icon default">
17+
<i v-if="fa" :class="icon"></i>
18+
<span v-else-if="icon">{{ icon }}</span>
19+
<LoadingIcon v-else />
20+
</span>
21+
<span class="text"><slot></slot></span>
22+
</span>
23+
</button>
24+
</template>
25+
26+
<script>
27+
import LoadingIcon from './LoadingIcon.vue';
28+
export default {
29+
components: { LoadingIcon },
30+
name: "AsyncButton",
31+
props: {
32+
fn: {
33+
// Asynchronous function to execute when the button is clicked
34+
type: Function,
35+
required: true
36+
},
37+
fa: {
38+
// Whether to use Font Awesome icons or not
39+
type: Boolean,
40+
default: false
41+
},
42+
confirm: {
43+
// Show a confirmation checkmark once the async action has succeeded
44+
type: Boolean,
45+
default: false
46+
},
47+
icon: {
48+
// fa=true: The Font Awesome icon class
49+
// fa=false: A unicode symbol
50+
type: String,
51+
default: ''
52+
},
53+
title: {
54+
// Tooltip text
55+
type: String,
56+
default: null
57+
},
58+
disabled: {
59+
// Disable the button
60+
type: Boolean,
61+
default: false
62+
},
63+
consistent: {
64+
// Whether the button should show the same icon for the loading animation
65+
type: Boolean,
66+
default: false
67+
}
68+
},
69+
data() {
70+
return {
71+
loading: false,
72+
asyncState: null
73+
};
74+
},
75+
computed: {
76+
loadingClasses() {
77+
let classes = this.consistent ? this.icon.split(' ') : ['fas', 'fa-spinner'];
78+
classes.push('fa-spin');
79+
return classes;
80+
}
81+
},
82+
methods: {
83+
async update(event) {
84+
if (this.asyncState !== null || this.disabled) {
85+
return;
86+
}
87+
try {
88+
this.$emit('before', event);
89+
this.loading = true;
90+
this.asyncState = await this.fn(event);
91+
if (!this.confirm) {
92+
this.asyncState = null
93+
}
94+
else if (typeof this.asyncState !== 'boolean') {
95+
this.asyncState = true;
96+
}
97+
} catch(e) {
98+
this.asyncState = false;
99+
} finally {
100+
this.loading = false;
101+
this.$emit('after', this.asyncState);
102+
if (this.confirm) {
103+
setTimeout(() => this.asyncState = null, 3000);
104+
}
105+
}
106+
}
107+
}
108+
}
109+
</script>
110+
111+
<style scoped lang="scss">
112+
.async-button {
113+
min-width: 2em;
114+
115+
&:not(.awesome) {
116+
.icon, .icon > svg, .icon > span {
117+
display: inline-block;
118+
width: 1em;
119+
height: 1em;
120+
font-size: 1em;
121+
line-height: 1em;
122+
}
123+
.button-content {
124+
display: flex;
125+
align-items: center;
126+
}
127+
}
128+
129+
.success {
130+
color: green;
131+
}
132+
.error {
133+
color: maroon;
134+
}
135+
.text {
136+
display: inline-block;
137+
margin-left: 0.5em;
138+
}
139+
.text:empty {
140+
display: none;
141+
}
142+
}
143+
</style>

components/internal/Loading.vue

+3-38
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,13 @@
11
<template>
22
<div class="vue-component loading-notice">
3-
<span class="loading">Loading</span>
3+
<span class="loading"><LoadingIcon rotate /> Loading...</span>
44
</div>
55
</template>
66

77
<script>
8+
import LoadingIcon from './LoadingIcon.vue'
89
export default {
10+
components: { LoadingIcon },
911
name: 'Loading'
1012
}
1113
</script>
12-
13-
<style lang="scss">
14-
.vue-component.loading-notice {
15-
.loading:after {
16-
content: '.';
17-
animation: dots 1.25s steps(5, end) infinite;
18-
font-size: 1.5em;
19-
line-height: 1em;
20-
font-weight: bold;
21-
}
22-
}
23-
24-
@keyframes dots {
25-
0%, 20% {
26-
color: rgba(0,0,0,0);
27-
text-shadow:
28-
.25em 0 0 rgba(0,0,0,0),
29-
.5em 0 0 rgba(0,0,0,0);
30-
}
31-
40% {
32-
color: black;
33-
text-shadow:
34-
.25em 0 0 rgba(0,0,0,0),
35-
.5em 0 0 rgba(0,0,0,0);
36-
}
37-
60% {
38-
text-shadow:
39-
.25em 0 0 black,
40-
.5em 0 0 rgba(0,0,0,0);
41-
}
42-
80%, 100% {
43-
text-shadow:
44-
.25em 0 0 black,
45-
.5em 0 0 black;
46-
}
47-
}
48-
</style>

components/internal/LoadingIcon.vue

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<template>
2+
<svg class="loading-icon" :class="{rotate}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" version="1.1" x="0px" y="0px" viewBox="0 0 100 100" width="400px" height="400px">
3+
<path d="M78.271,21.729C71.028,14.486,61.028,10,50,10C27.944,10,10,27.944,10,50S27.944,90,50,90S90,72.056,90,50L80,50C80,66.542,66.542,80,50,80S20,66.542,20,50S33.458,20,50,20C58.271,20,65.771,23.365,71.203,28.797L60,40L90,40L90,10L78.271,21.729Z" stroke="none"></path>
4+
</svg>
5+
</template>
6+
7+
<script>
8+
export default {
9+
name: "LoadingIcon",
10+
props: {
11+
rotate: {
12+
type: Boolean,
13+
default: false
14+
}
15+
}
16+
}
17+
</script>
18+
19+
<style lang="scss" scoped>
20+
.loading-icon {
21+
display: inline-block;
22+
width: 1em;
23+
height: 1em;
24+
font-size: 1em;
25+
line-height: 1em;
26+
&.rotate {
27+
animation: loading-icon-rotate 1s infinite linear;
28+
}
29+
}
30+
@keyframes loading-icon-rotate {
31+
from {
32+
transform: rotate(0deg);
33+
}
34+
to {
35+
transform: rotate(359deg);
36+
}
37+
}
38+
</style>

0 commit comments

Comments
 (0)