Skip to content

Commit

Permalink
Simple pagination for DataTable (#98)
Browse files Browse the repository at this point in the history
* Load more button and event for tables

* Rework loading and add async button
  • Loading branch information
m-mohr authored Jan 4, 2025
1 parent f19696e commit f2ebbb9
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 50 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `DataTable`: Props `next` and `fa`

## [2.17.0] - 2024-08-12

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ A relatively simple table component to show a list of data.

- `columns` (object, required): The columns to show in the table.
- `data` (array, required): An array of objects containing the data to show.
- `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).
- `fa` (boolean): Whether to use Font Awesome icons or not. Defaults to `false`.

**Slots:**

Expand Down
34 changes: 27 additions & 7 deletions components/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
<slot name="toolbar"></slot>
</div>
<div class="filter" v-if="hasData">
<SearchBox v-model="filterValue" :compact="true" />
<SearchBox v-model="filterValue" :placeholder="searchPlaceholder" :compact="true" />
</div>
</div>
<table v-if="hasData">
<thead>
<tr>
<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>
<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>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in view" :key="i">
<td v-for="(col, id) in columns" v-show="!col.hide" :key="`${col.name}_${i}`"
<td v-for="(col, id) in columns" v-show="!col.hide" :key="`${col.name}_${id}`"
:class="[id, {'edit': canEdit(col)}]"
:title="canEdit(col) ? 'Double-click to change the value' : false"
@dblclick="onDblClick($event, row, col, id)"
Expand All @@ -37,6 +37,7 @@
</tbody>
</table>
<div class="no-data" v-else>{{ noDataMessage }}</div>
<AsyncButton v-if="hasMore" :fa="fa" icon="fas fa-sync" class="has-more-button" :fn="next">Load more...</AsyncButton>
</div>
</template>

Expand All @@ -47,6 +48,7 @@ import { DataTypes, Formatters } from '@radiantearth/stac-fields';
export default {
name: 'DataTable',
components: {
AsyncButton: () => import('./internal/AsyncButton.vue'),
SearchBox: () => import('./SearchBox.vue')
},
props: {
Expand All @@ -57,6 +59,15 @@ export default {
data: {
type: Array,
default: () => ([])
},
next: {
type: Function,
default: null
},
fa: {
// Whether to use Font Awesome icons or not
type: Boolean,
default: false
}
},
data() {
Expand Down Expand Up @@ -85,6 +96,9 @@ export default {
columns: {
immediate: true,
handler() {
if (this.hasMore) {
return;
}
for(let id in this.columns) {
let direction = this.columns[id].sort;
if (['asc', 'desc'].includes(direction)) {
Expand All @@ -96,6 +110,9 @@ export default {
}
},
computed: {
hasMore() {
return typeof this.next === 'function';
},
columnCount() {
return Object.keys(this.columns).length;
},
Expand All @@ -104,6 +121,9 @@ export default {
},
hasFilter() {
return (typeof this.filterValue === 'string' && this.filterValue.length > 0) ? true : false;
},
searchPlaceholder() {
return this.hasMore ? `Search through subset of loaded data...` : `Search...`;
}
},
beforeCreate() {
Expand Down Expand Up @@ -196,7 +216,7 @@ export default {
thClasses(id) {
let col = this.columns[id];
let classes = [id];
if (col.sort !== false) {
if (!this.hasMore && col.sort !== false) {
classes.push('sortable');
if (this.sortState.id === id) {
classes.push('sort-' + this.sortState.direction);
Expand All @@ -206,7 +226,7 @@ export default {
},
thTitle(id) {
let col = this.columns[id];
if (col.sort !== false) {
if (!this.hasMore && col.sort !== false) {
if (this.sortState.id === id && this.sortState.direction === 'asc') {
return "Click to sort column in descending order";
}
Expand All @@ -217,7 +237,7 @@ export default {
return null;
},
enableSort(id, direction = null) {
if (this.columns[id].sort === false) {
if (this.hasMore || this.columns[id].sort === false) {
return;
}
if (direction === null) {
Expand Down Expand Up @@ -358,7 +378,7 @@ export default {
text-align: right;
padding-left: 1em;
min-width: 4em;
max-width: 20em;
max-width: 30em;
.edit {
cursor: pointer;
}
Expand Down
7 changes: 4 additions & 3 deletions components/FederationMissingNotice.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<template>
<section v-if="services" class="vue-component message-block federation federation-backends">
<button v-if="retry" type="button" class="retry" @click="retry">
<slot name="button-text">Retry</slot>
</button>
<AsyncButton v-if="retry" confirm class="retry" :fn="retry">Retry</AsyncButton>
<strong class="header">Incomplete</strong>
<p>
The following list is incomplete as at least one of the services in the federation is currently not available.
Expand All @@ -17,6 +15,9 @@ import Utils from '../utils';
export default {
name: 'FederationMissingNotice',
components: {
AsyncButton: () => import('./internal/AsyncButton.vue')
},
mixins: [
FederationMixin
],
Expand Down
143 changes: 143 additions & 0 deletions components/internal/AsyncButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<button type="button" v-show="fn" :title="title" :disabled="disabled" class="async-button" :class="{awesome: fa}" @click="update">
<span class="button-content">
<span v-if="loading" class="icon loading">
<i v-if="fa" :class="loadingClasses"></i>
<LoadingIcon rotate v-else />
</span>
<span v-else-if="asyncState === true" class="icon success">
<i v-if="fa" class="fas fa-check"></i>
<span v-else>✔️</span>
</span>
<span v-else-if="asyncState === false" class="icon error">
<i v-if="fa" class="fas fa-times"></i>
<span v-else>❌</span>
</span>
<span v-else class="icon default">
<i v-if="fa" :class="icon"></i>
<span v-else-if="icon">{{ icon }}</span>
<LoadingIcon v-else />
</span>
<span class="text"><slot></slot></span>
</span>
</button>
</template>

<script>
import LoadingIcon from './LoadingIcon.vue';
export default {
components: { LoadingIcon },
name: "AsyncButton",
props: {
fn: {
// Asynchronous function to execute when the button is clicked
type: Function,
required: true
},
fa: {
// Whether to use Font Awesome icons or not
type: Boolean,
default: false
},
confirm: {
// Show a confirmation checkmark once the async action has succeeded
type: Boolean,
default: false
},
icon: {
// fa=true: The Font Awesome icon class
// fa=false: A unicode symbol
type: String,
default: ''
},
title: {
// Tooltip text
type: String,
default: null
},
disabled: {
// Disable the button
type: Boolean,
default: false
},
consistent: {
// Whether the button should show the same icon for the loading animation
type: Boolean,
default: false
}
},
data() {
return {
loading: false,
asyncState: null
};
},
computed: {
loadingClasses() {
let classes = this.consistent ? this.icon.split(' ') : ['fas', 'fa-spinner'];
classes.push('fa-spin');
return classes;
}
},
methods: {
async update(event) {
if (this.asyncState !== null || this.disabled) {
return;
}
try {
this.$emit('before', event);
this.loading = true;
this.asyncState = await this.fn(event);
if (!this.confirm) {
this.asyncState = null
}
else if (typeof this.asyncState !== 'boolean') {
this.asyncState = true;
}
} catch(e) {
this.asyncState = false;
} finally {
this.loading = false;
this.$emit('after', this.asyncState);
if (this.confirm) {
setTimeout(() => this.asyncState = null, 3000);
}
}
}
}
}
</script>

<style scoped lang="scss">
.async-button {
min-width: 2em;
&:not(.awesome) {
.icon, .icon > svg, .icon > span {
display: inline-block;
width: 1em;
height: 1em;
font-size: 1em;
line-height: 1em;
}
.button-content {
display: flex;
align-items: center;
}
}
.success {
color: green;
}
.error {
color: maroon;
}
.text {
display: inline-block;
margin-left: 0.5em;
}
.text:empty {
display: none;
}
}
</style>
41 changes: 3 additions & 38 deletions components/internal/Loading.vue
Original file line number Diff line number Diff line change
@@ -1,48 +1,13 @@
<template>
<div class="vue-component loading-notice">
<span class="loading">Loading</span>
<span class="loading"><LoadingIcon rotate /> Loading...</span>
</div>
</template>

<script>
import LoadingIcon from './LoadingIcon.vue'
export default {
components: { LoadingIcon },
name: 'Loading'
}
</script>

<style lang="scss">
.vue-component.loading-notice {
.loading:after {
content: '.';
animation: dots 1.25s steps(5, end) infinite;
font-size: 1.5em;
line-height: 1em;
font-weight: bold;
}
}
@keyframes dots {
0%, 20% {
color: rgba(0,0,0,0);
text-shadow:
.25em 0 0 rgba(0,0,0,0),
.5em 0 0 rgba(0,0,0,0);
}
40% {
color: black;
text-shadow:
.25em 0 0 rgba(0,0,0,0),
.5em 0 0 rgba(0,0,0,0);
}
60% {
text-shadow:
.25em 0 0 black,
.5em 0 0 rgba(0,0,0,0);
}
80%, 100% {
text-shadow:
.25em 0 0 black,
.5em 0 0 black;
}
}
</style>
38 changes: 38 additions & 0 deletions components/internal/LoadingIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<template>
<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">
<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>
</svg>
</template>

<script>
export default {
name: "LoadingIcon",
props: {
rotate: {
type: Boolean,
default: false
}
}
}
</script>

<style lang="scss" scoped>
.loading-icon {
display: inline-block;
width: 1em;
height: 1em;
font-size: 1em;
line-height: 1em;
&.rotate {
animation: loading-icon-rotate 1s infinite linear;
}
}
@keyframes loading-icon-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
</style>
Loading

0 comments on commit f2ebbb9

Please sign in to comment.