760 lines
24 KiB
Vue
760 lines
24 KiB
Vue
<template>
|
|
<div
|
|
:id="id"
|
|
class="datatable-wrapper"
|
|
>
|
|
<div
|
|
v-if="showToolbar"
|
|
class="datatable-toolbar-top card-body"
|
|
>
|
|
<div class="row align-items-center">
|
|
<div
|
|
v-if="showPagination"
|
|
class="col-xl-6 col-lg-5 col-md-12 col-sm-12"
|
|
>
|
|
<pagination
|
|
v-model:current-page="currentPage"
|
|
:total="totalRows"
|
|
:per-page="perPage"
|
|
@change="onPageChange"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="col-xl-6 col-lg-5 col-md-12 col-sm-12"
|
|
>
|
|
|
|
</div>
|
|
<div
|
|
class="col-xl-6 col-lg-7 col-md-12 col-sm-12 d-flex my-2"
|
|
>
|
|
<div class="flex-fill">
|
|
<div class="input-group">
|
|
<span class="input-group-text">
|
|
<icon :icon="IconSearch" />
|
|
</span>
|
|
<input
|
|
v-model="searchPhrase"
|
|
class="form-control"
|
|
type="search"
|
|
:placeholder="$gettext('Search')"
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="flex-shrink-1 ps-3">
|
|
<div class="btn-group actions">
|
|
<button
|
|
type="button"
|
|
data-bs-tooltip
|
|
class="btn btn-secondary"
|
|
data-bs-placement="left"
|
|
:title="$gettext('Refresh rows')"
|
|
@click="onClickRefresh"
|
|
>
|
|
<icon :icon="IconRefresh" />
|
|
</button>
|
|
|
|
<div
|
|
v-if="paginated"
|
|
class="dropdown btn-group"
|
|
role="group"
|
|
>
|
|
<button
|
|
type="button"
|
|
data-bs-tooltip
|
|
class="btn btn-secondary dropdown-toggle"
|
|
data-bs-toggle="dropdown"
|
|
aria-expanded="false"
|
|
data-bs-placement="left"
|
|
:title="$gettext('Items per page')"
|
|
>
|
|
<span>
|
|
{{ perPageLabel }}
|
|
</span>
|
|
<span class="caret" />
|
|
</button>
|
|
<ul class="dropdown-menu">
|
|
<li
|
|
v-for="pageOption in pageOptions"
|
|
:key="pageOption"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="dropdown-item"
|
|
:class="(pageOption === perPage) ? 'active' : ''"
|
|
@click="settings.perPage = pageOption"
|
|
>
|
|
{{ getPerPageLabel(pageOption) }}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div
|
|
v-if="selectFields"
|
|
class="dropdown btn-group"
|
|
role="group"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary dropdown-toggle"
|
|
data-bs-toggle="dropdown"
|
|
aria-expanded="false"
|
|
data-bs-placement="left"
|
|
:title="$gettext('Display fields')"
|
|
>
|
|
<icon :icon="IconFilterList" />
|
|
<span class="caret" />
|
|
</button>
|
|
<div class="dropdown-menu">
|
|
<div class="px-3 py-1">
|
|
<form-multi-check
|
|
id="field_select"
|
|
v-model="visibleFieldKeys"
|
|
:options="selectableFieldOptions"
|
|
stacked
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="datatable-main"
|
|
:class="[
|
|
responsiveClass
|
|
]"
|
|
>
|
|
<table
|
|
class="table align-middle table-striped table-hover"
|
|
:class="[
|
|
(selectable) ? 'table-selectable' : ''
|
|
]"
|
|
>
|
|
<caption v-if="slots.caption">
|
|
<slot name="caption" />
|
|
</caption>
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
v-if="selectable"
|
|
class="checkbox"
|
|
>
|
|
<label class="form-check">
|
|
<form-checkbox
|
|
autocomplete="off"
|
|
:model-value="isAllChecked"
|
|
@update:model-value="checkAll"
|
|
/>
|
|
<span class="visually-hidden">
|
|
<template v-if="isAllChecked">
|
|
{{ $gettext('Unselect All Rows') }}
|
|
</template>
|
|
<template v-else>
|
|
{{ $gettext('Select All Rows') }}
|
|
</template>
|
|
</span>
|
|
</label>
|
|
</th>
|
|
<th
|
|
v-for="(column) in visibleFields"
|
|
:key="column.key+'header'"
|
|
:class="[
|
|
column.class,
|
|
(column.sortable) ? 'sortable' : ''
|
|
]"
|
|
@click.stop="sort(column)"
|
|
>
|
|
<slot
|
|
:name="'header('+column.key+')'"
|
|
v-bind="column"
|
|
>
|
|
<div class="d-flex align-items-center">
|
|
{{ column.label }}
|
|
|
|
<template v-if="column.sortable && sortField === column.key">
|
|
<icon :icon="(sortOrder === 'asc') ? IconArrowDropDown : IconArrowDropUp" />
|
|
</template>
|
|
</div>
|
|
</slot>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template v-if="isLoading && hideOnLoading">
|
|
<tr>
|
|
<td
|
|
:colspan="columnCount"
|
|
class="text-center p-5"
|
|
>
|
|
<div
|
|
class="spinner-border"
|
|
role="status"
|
|
>
|
|
<span class="visually-hidden">{{ $gettext('Loading') }}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template v-else-if="visibleItems.length === 0">
|
|
<tr>
|
|
<td :colspan="columnCount">
|
|
<slot name="empty">
|
|
{{ $gettext('No records.') }}
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
<template
|
|
v-for="(row, index) in visibleItems"
|
|
v-else
|
|
:key="index"
|
|
>
|
|
<tr
|
|
:class="[
|
|
isActiveDetailRow(row) ? 'table-active' : ''
|
|
]"
|
|
>
|
|
<td
|
|
v-if="selectable"
|
|
class="checkbox"
|
|
>
|
|
<label class="form-check">
|
|
<form-checkbox
|
|
autocomplete="off"
|
|
:model-value="isRowChecked(row)"
|
|
@update:model-value="checkRow(row)"
|
|
/>
|
|
<span class="visually-hidden">
|
|
<template v-if="isRowChecked(row)">
|
|
{{ $gettext('Unselect Row') }}
|
|
</template>
|
|
<template v-else>
|
|
{{ $gettext('Select Row') }}
|
|
</template>
|
|
</span>
|
|
</label>
|
|
</td>
|
|
<td
|
|
v-for="column in visibleFields"
|
|
:key="column.key+':'+index"
|
|
:class="column.class"
|
|
>
|
|
<slot
|
|
:name="'cell('+column.key+')'"
|
|
:column="column"
|
|
:item="row"
|
|
:is-active="isActiveDetailRow(row)"
|
|
:toggle-details="() => toggleDetails(row)"
|
|
>
|
|
{{ getColumnValue(column, row) }}
|
|
</slot>
|
|
</td>
|
|
</tr>
|
|
<tr
|
|
v-if="isActiveDetailRow(row)"
|
|
:key="index+':detail'"
|
|
class="table-active"
|
|
>
|
|
<td :colspan="columnCount">
|
|
<slot
|
|
name="detail"
|
|
:item="row"
|
|
:index="index"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div
|
|
v-if="showToolbar"
|
|
class="datatable-toolbar-bottom card-body"
|
|
>
|
|
<pagination
|
|
v-if="showPagination"
|
|
v-model:current-page="currentPage"
|
|
:total="totalRows"
|
|
:per-page="perPage"
|
|
@change="onPageChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts" generic="Row extends object">
|
|
import {filter, forEach, get, includes, indexOf, isEmpty, map, reverse, slice, some} from 'lodash';
|
|
import Icon from './Icon.vue';
|
|
import {computed, onMounted, ref, shallowRef, toRaw, toRef, useSlots, watch} from "vue";
|
|
import {watchDebounced} from "@vueuse/core";
|
|
import {useAxios} from "~/vendor/axios";
|
|
import FormMultiCheck from "~/components/Form/FormMultiCheck.vue";
|
|
import FormCheckbox from "~/components/Form/FormCheckbox.vue";
|
|
import Pagination from "~/components/Common/Pagination.vue";
|
|
import useOptionalStorage from "~/functions/useOptionalStorage";
|
|
import {IconArrowDropDown, IconArrowDropUp, IconFilterList, IconRefresh, IconSearch} from "~/components/Common/icons";
|
|
import {useAzuraCast} from "~/vendor/azuracast.ts";
|
|
|
|
export interface DataTableProps {
|
|
id?: string,
|
|
fields: DataTableField[],
|
|
apiUrl?: string, // URL to fetch for server-side data
|
|
items?: Row[], // Array of items for client-side data
|
|
responsive?: boolean | string, // Make table responsive (boolean or CSS class for specific responsiveness width)
|
|
paginated?: boolean, // Enable pagination.
|
|
loading?: boolean, // Pass to override the "loading" property for this table.
|
|
hideOnLoading?: boolean, // Replace the table contents with a loading animation when data is being retrieved.
|
|
showToolbar?: boolean, // Show the header "Toolbar" with search, refresh, per-page, etc.
|
|
pageOptions?: number[],
|
|
defaultPerPage?: number,
|
|
selectable?: boolean, // Allow selecting individual rows with checkboxes at the side of each row
|
|
detailed?: boolean, // Allow showing "Detail" panel for selected rows.
|
|
selectFields?: boolean, // Allow selecting which columns are visible.
|
|
handleClientSide?: boolean, // Handle searching, sorting and pagination client-side without API calls.
|
|
requestConfig?(config: object): object, // Custom server-side request configuration (pre-request)
|
|
requestProcess?(rawData: object[]): Row[], // Custom server-side request result processing (post-request)
|
|
}
|
|
|
|
const props = withDefaults(defineProps<DataTableProps>(), {
|
|
id: null,
|
|
apiUrl: null,
|
|
items: null,
|
|
responsive: () => true,
|
|
paginated: false,
|
|
loading: false,
|
|
hideOnLoading: true,
|
|
showToolbar: true,
|
|
pageOptions: () => [10, 25, 50, 100, 250, 500, 0],
|
|
defaultPerPage: 10,
|
|
selectable: false,
|
|
detailed: false,
|
|
selectFields: false,
|
|
handleClientSide: false,
|
|
requestConfig: undefined,
|
|
requestProcess: undefined
|
|
});
|
|
|
|
const slots = useSlots();
|
|
|
|
const emit = defineEmits([
|
|
'refresh-clicked',
|
|
'refreshed',
|
|
'row-selected',
|
|
'filtered',
|
|
'data-loaded'
|
|
]);
|
|
|
|
const selectedRows = shallowRef<Row[]>([]);
|
|
|
|
const searchPhrase = ref<string>('');
|
|
const currentPage = ref<number>(1);
|
|
const flushCache = ref<boolean>(false);
|
|
|
|
const sortField = ref<string | null>(null);
|
|
const sortOrder = ref<string | null>(null);
|
|
|
|
const isLoading = ref<boolean>(false);
|
|
|
|
watch(toRef(props, 'loading'), (newLoading: boolean) => {
|
|
isLoading.value = newLoading;
|
|
});
|
|
|
|
const visibleItems = shallowRef<Row[]>([]);
|
|
const totalRows = ref(0);
|
|
|
|
const activeDetailsRow = shallowRef<Row>(null);
|
|
|
|
export interface DataTableField {
|
|
key: string,
|
|
label: string,
|
|
isRowHeader?: boolean,
|
|
sortable?: boolean,
|
|
selectable?: boolean,
|
|
visible?: boolean,
|
|
class?: string | Array<any>,
|
|
formatter?(column: any, key: string, row: any): string,
|
|
}
|
|
|
|
const allFields = computed<DataTableField[]>(() => {
|
|
return map(props.fields, (field: DataTableField) => {
|
|
return {
|
|
label: '',
|
|
isRowHeader: false,
|
|
sortable: false,
|
|
selectable: false,
|
|
visible: true,
|
|
formatter: null,
|
|
class: null,
|
|
...field
|
|
};
|
|
});
|
|
});
|
|
|
|
const selectableFields = computed<DataTableField[]>(() => {
|
|
return filter({...allFields.value}, (field) => {
|
|
return field.selectable;
|
|
});
|
|
});
|
|
|
|
const selectableFieldOptions = computed(() => map(selectableFields.value, (field) => {
|
|
return {
|
|
value: field.key,
|
|
text: field.label
|
|
};
|
|
}));
|
|
|
|
const defaultSelectableFields = computed(() => {
|
|
return filter({...selectableFields.value}, (field) => {
|
|
return field.visible;
|
|
});
|
|
});
|
|
|
|
const settings = useOptionalStorage(
|
|
'datatable_' + props.id + '_settings',
|
|
{
|
|
sortBy: null,
|
|
sortDesc: false,
|
|
perPage: props.defaultPerPage,
|
|
visibleFieldKeys: map(defaultSelectableFields.value, (field) => field.key),
|
|
},
|
|
{
|
|
mergeDefaults: true
|
|
}
|
|
);
|
|
|
|
const visibleFieldKeys = computed({
|
|
get: () => {
|
|
const settingsKeys = toRaw(settings.value.visibleFieldKeys);
|
|
if (!isEmpty(settingsKeys)) {
|
|
return settingsKeys;
|
|
}
|
|
|
|
return map(defaultSelectableFields.value, (field) => field.key);
|
|
},
|
|
set: (newValue) => {
|
|
if (isEmpty(newValue)) {
|
|
newValue = map(defaultSelectableFields.value, (field) => field.key);
|
|
}
|
|
|
|
settings.value.visibleFieldKeys = newValue;
|
|
}
|
|
});
|
|
|
|
const perPage = computed<number>(() => {
|
|
if (!props.paginated) {
|
|
return -1;
|
|
}
|
|
|
|
return settings.value?.perPage ?? props.defaultPerPage;
|
|
});
|
|
|
|
const visibleFields = computed<DataTableField[]>(() => {
|
|
const fields = allFields.value.slice();
|
|
|
|
if (!props.selectFields) {
|
|
return fields;
|
|
}
|
|
|
|
const visibleFieldsKeysValue = visibleFieldKeys.value;
|
|
|
|
return filter(fields, (field) => {
|
|
if (!field.selectable) {
|
|
return true;
|
|
}
|
|
|
|
return includes(visibleFieldsKeysValue, field.key);
|
|
});
|
|
});
|
|
|
|
const getPerPageLabel = (num): string => {
|
|
return (num === 0) ? 'All' : num.toString();
|
|
};
|
|
|
|
const perPageLabel = computed<string>(() => {
|
|
return getPerPageLabel(perPage.value);
|
|
});
|
|
|
|
const showPagination = computed<boolean>(() => {
|
|
return props.paginated && perPage.value !== 0;
|
|
});
|
|
|
|
const {localeShort} = useAzuraCast();
|
|
|
|
const refreshClientSide = () => {
|
|
// Handle filtration client-side.
|
|
let itemsOnPage = filter(toRaw(props.items), (item) =>
|
|
Object.entries(item).filter((item) => {
|
|
const [key, val] = item;
|
|
if (!val || key[0] === '_') {
|
|
return false;
|
|
}
|
|
|
|
const itemValue = typeof val === 'object'
|
|
? JSON.stringify(Object.values(val))
|
|
: typeof val === 'string'
|
|
? val : val.toString();
|
|
|
|
return itemValue.toLowerCase().includes(searchPhrase.value.toLowerCase())
|
|
}).length > 0
|
|
);
|
|
|
|
totalRows.value = itemsOnPage.length;
|
|
|
|
// Handle sorting client-side.
|
|
if (sortField.value) {
|
|
const collator = new Intl.Collator(localeShort, {numeric: true, sensitivity: 'base'});
|
|
|
|
itemsOnPage = itemsOnPage.sort(
|
|
(a, b) => collator.compare(get(a, sortField.value), get(b, sortField.value))
|
|
);
|
|
|
|
if (sortOrder.value === 'desc') {
|
|
itemsOnPage = reverse(itemsOnPage);
|
|
}
|
|
}
|
|
|
|
// Handle pagination client-side.
|
|
if (props.paginated && perPage.value > 0) {
|
|
itemsOnPage = slice(
|
|
itemsOnPage,
|
|
(currentPage.value - 1) * perPage.value,
|
|
currentPage.value * perPage.value
|
|
);
|
|
}
|
|
|
|
visibleItems.value = itemsOnPage;
|
|
emit('refreshed');
|
|
};
|
|
|
|
watch(toRef(props, 'items'), () => {
|
|
if (props.handleClientSide) {
|
|
refreshClientSide();
|
|
}
|
|
}, {
|
|
immediate: true
|
|
});
|
|
|
|
const {axios} = useAxios();
|
|
|
|
const refreshServerSide = () => {
|
|
const queryParams: {
|
|
[key: string]: any
|
|
} = {
|
|
internal: true
|
|
};
|
|
|
|
if (props.handleClientSide) {
|
|
queryParams.rowCount = 0;
|
|
} else {
|
|
if (props.paginated) {
|
|
queryParams.rowCount = perPage.value;
|
|
queryParams.current = (perPage.value !== 0) ? currentPage.value : 1;
|
|
} else {
|
|
queryParams.rowCount = 0;
|
|
}
|
|
|
|
if (flushCache.value) {
|
|
queryParams.flushCache = true;
|
|
}
|
|
|
|
if (searchPhrase.value !== '') {
|
|
queryParams.searchPhrase = searchPhrase.value;
|
|
}
|
|
|
|
if ('' !== sortField.value) {
|
|
queryParams.sort = sortField.value;
|
|
queryParams.sortOrder = (sortOrder.value === 'desc') ? 'DESC' : 'ASC';
|
|
}
|
|
}
|
|
|
|
let requestConfig = {params: queryParams};
|
|
if (typeof props.requestConfig === 'function') {
|
|
requestConfig = props.requestConfig(requestConfig);
|
|
}
|
|
|
|
isLoading.value = true;
|
|
|
|
return axios.get(props.apiUrl, requestConfig).then((resp) => {
|
|
totalRows.value = resp.data.total;
|
|
|
|
let rows = resp.data.rows;
|
|
if (typeof props.requestProcess === 'function') {
|
|
rows = props.requestProcess(rows);
|
|
}
|
|
|
|
emit('data-loaded', rows);
|
|
visibleItems.value = rows;
|
|
}).catch((err) => {
|
|
totalRows.value = 0;
|
|
console.error(err.response.data.message);
|
|
}).finally(() => {
|
|
isLoading.value = false;
|
|
flushCache.value = false;
|
|
emit('refreshed');
|
|
});
|
|
}
|
|
|
|
const refresh = () => {
|
|
selectedRows.value = [];
|
|
activeDetailsRow.value = null;
|
|
|
|
if (props.handleClientSide) {
|
|
refreshClientSide();
|
|
} else {
|
|
refreshServerSide();
|
|
}
|
|
};
|
|
|
|
const onPageChange = (p) => {
|
|
currentPage.value = p;
|
|
refresh();
|
|
}
|
|
|
|
const relist = () => {
|
|
flushCache.value = true;
|
|
refresh();
|
|
};
|
|
|
|
const onClickRefresh = (e) => {
|
|
emit('refresh-clicked', e);
|
|
|
|
if (e.shiftKey) {
|
|
relist();
|
|
} else {
|
|
refresh();
|
|
}
|
|
};
|
|
|
|
const navigate = () => {
|
|
searchPhrase.value = '';
|
|
currentPage.value = 1;
|
|
relist();
|
|
};
|
|
|
|
const setFilter = (newTerm) => {
|
|
searchPhrase.value = newTerm;
|
|
};
|
|
|
|
watch(perPage, () => {
|
|
currentPage.value = 1;
|
|
relist();
|
|
});
|
|
|
|
watchDebounced(searchPhrase, (newSearchPhrase) => {
|
|
currentPage.value = 1;
|
|
relist();
|
|
|
|
emit('filtered', newSearchPhrase);
|
|
}, {
|
|
debounce: 500,
|
|
maxWait: 1000
|
|
});
|
|
|
|
onMounted(refresh);
|
|
|
|
const isAllChecked = computed<boolean>(() => {
|
|
if (visibleItems.value.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return !some(visibleItems.value, (currentVisibleRow) => {
|
|
return indexOf(selectedRows.value, currentVisibleRow) < 0;
|
|
});
|
|
});
|
|
|
|
const isRowChecked = (row: Row) => {
|
|
return indexOf(selectedRows.value, row) >= 0;
|
|
};
|
|
|
|
const columnCount = computed(() => {
|
|
let count = visibleFields.value.length;
|
|
count += props.selectable ? 1 : 0;
|
|
return count
|
|
});
|
|
|
|
const sort = (column: DataTableField) => {
|
|
if (!column.sortable) {
|
|
return;
|
|
}
|
|
|
|
if (sortField.value === column.key && sortOrder.value === 'desc') {
|
|
sortOrder.value = null;
|
|
sortField.value = null;
|
|
} else {
|
|
sortOrder.value = (sortField.value === column.key)
|
|
? 'desc'
|
|
: 'asc';
|
|
sortField.value = column.key;
|
|
}
|
|
|
|
refresh();
|
|
};
|
|
|
|
const checkRow = (row: Row) => {
|
|
const newSelectedRows = selectedRows.value;
|
|
|
|
if (isRowChecked(row)) {
|
|
const index = indexOf(newSelectedRows, row);
|
|
if (index >= 0) {
|
|
newSelectedRows.splice(index, 1);
|
|
}
|
|
} else {
|
|
newSelectedRows.push(row);
|
|
}
|
|
|
|
emit('row-selected', newSelectedRows);
|
|
selectedRows.value = newSelectedRows;
|
|
}
|
|
|
|
const checkAll = () => {
|
|
const newSelectedRows = [];
|
|
|
|
if (!isAllChecked.value) {
|
|
forEach(visibleItems.value, (currentRow) => {
|
|
newSelectedRows.push(currentRow);
|
|
});
|
|
}
|
|
|
|
emit('row-selected', newSelectedRows);
|
|
selectedRows.value = newSelectedRows;
|
|
};
|
|
|
|
const isActiveDetailRow = (row: Row) => {
|
|
return activeDetailsRow.value === row;
|
|
};
|
|
|
|
const toggleDetails = (row: Row) => {
|
|
activeDetailsRow.value = isActiveDetailRow(row)
|
|
? null
|
|
: row;
|
|
};
|
|
|
|
const responsiveClass = computed(() => {
|
|
if (typeof props.responsive === 'string') {
|
|
return props.responsive;
|
|
}
|
|
|
|
return (props.responsive ? 'table-responsive' : '');
|
|
});
|
|
|
|
const getColumnValue = (field: DataTableField, row: Row): string => {
|
|
const columnValue = get(row, field.key, null);
|
|
|
|
return (field.formatter)
|
|
? field.formatter(columnValue, field.key, row)
|
|
: columnValue;
|
|
}
|
|
|
|
defineExpose({
|
|
refresh,
|
|
relist,
|
|
navigate,
|
|
setFilter,
|
|
toggleDetails
|
|
});
|
|
</script>
|