Таблица на Vue 3. Создание компонента

Данная статья появилась на свет по двум причинам. Во-первых ряд читателей приходили с вопросами о создании компонента-таблицы на Vue. Во-вторых в прошлой статье мы подошли к продвинутому использованию слотов. И такой компонент послужил бы неплохим кандидатом для применения данных техник. Сами техники эффективного расширения компонентов слотами будут продемонстрированы в следующей статье. А в этой будет описано создание начальной версии таблицы, которую потом можно будет развивать.

Содержание

Почему Vue 3?

На данный момент Vue версии 2 с Options API уже считается сильно устаревшим. Переход на Composition API возможен и в последних релизах второй версии. Если вы все еще не используете новый API, то рекомендую начать как можно скорее. Обновиться максимально в рамках версии 2, а, если возможно, то перейти на версию 3. Чтобы не использовать устаревшие подходы данная статья будет содержать примеры на базе Composition API. В идеале нужен бы был еще TypeScript. Но для расширения круга возможных читателей пока будет использоваться JavaScript.

Описание результата

И так создаваемый компонент будет представлять из себя таблицу с данными. Данные будут разбиты на страницы (порции выборки). В таблицу будет загружена только одна страница данных. В нижней части таблицы (футере) будет переключатель страниц. В верхней части таблицы можно будет управлять сортировкой кликая по заголовку столбца.

Внешний вид компонента-таблицы на Vue3

Данные с серверного API

Фокусом статьи будет front end. Поэтому данные мы будем получать готовые. Предлагается использовать сервис JSONPlaceholder, откуда получим тестовые строки. Используется маршрут https://jsonplaceholder.typicode.com/posts, который выдает данные по сообщениям (posts) с возможностью указания номера страницы (параметр _page), числа записей на странице (_limit) и параметров упорядочивания (сортировки) данных (_sort, _order).

Описание работы примера

Код примера таблицы на Vue 3 готовый к запуску можете загрузить из репозитория. В нем содержатся следующие файлы:

  • src/components/PostDataTable.vue – собственно, компонент-таблица
  • src/api/posts.js – обращение к API сервера
  • src/api/helpers.js – преобразование данных компонента в параметры для API сервера (и обратно)
  • src/assets – Общие CSS стили приложения
  • src/App.vue – компонент приложения

Как работает компонент таблицы пояснено с помощью комментариев в коде.

<template>
  <table class="data-table">
    <thead>
      <tr :class="{ 'data-table__row_loading': loading }">
        <!-- Создание заголовка для каждой колонки  -->
        <th
          v-for="field in headers"
          :key="field.key"
          :style="{ width: field.width, minWidth: field.width }"
          :class="{ 'data-table__th-sortable': field.sortable }"
          @click="handleToggleSortAction(field)"
        >
          <!-- Текст в заголовке с название столбца -->
          {{ field.title }}
          <!-- Пиктограмма-стрелка для отображения направления сортировки -->
          <template v-if="options.sortBy === field.key">
            <svg-icon
              type="mdi"
              class="icon"
              v-if="options.sortDir === 'asc'"
              :path="ascSortIcon"
            ></svg-icon>
            <svg-icon
              type="mdi"
              class="icon"
              v-if="options.sortDir === 'desc'"
              :path="descSortIcon"
            ></svg-icon>
          </template>
        </th>
      </tr>
      <!-- Отображение анимации загрузки -->
      <tr v-if="loading" class="data-datatable__progress">
        <td :colspan="headers.length">
          <div class="progress-bar">
            <div class="progress-bar__value"></div>
          </div>
        </td>
      </tr>
    </thead>
    <tbody>
      <!-- Создание строки для каждой записи данных  -->
      <tr v-for="item in pageItems" :key="item" :class="{ 'data-table__row_loading': loading }">
        <!-- Создание ячейки с данными в каждой строке -->
        <td v-for="field in headers" :key="field.key" :style="{ textAlign: field.align }">
          <!-- Содержимое ячейки -->
          {{ item[field.key] }}
        </td>
      </tr>
    </tbody>
    <tfoot>
      <tr>
        <td :colspan="headers.length">
          <div class="data-table__footer-content">
            <!-- Кнопка перехода на предыдущую страницу -->
            <button
              class="icon-btn"
              :disabled="options.page === 1"
              @click="handlePreviousPageAction"
            >
              <svg-icon type="mdi" :path="previousIcon"></svg-icon>
            </button>
            page #{{ options.page }} from {{ pagesCount }}
            <!-- Кнопка перехода на следующую страницу -->
            <button
              class="icon-btn"
              :disabled="options.page >= pagesCount"
              @click="handleNextPageAction"
            >
              <svg-icon type="mdi" :path="nextIcon"></svg-icon>
            </button>
          </div>
        </td>
      </tr>
    </tfoot>
  </table>
</template>

<script setup>
import { getPosts } from '@/api/posts.js'
import { computed, onMounted, reactive, ref } from 'vue'
import SvgIcon from '@jamescoyle/vue-icon'
import {
  mdiArrowLeft as previousIcon,
  mdiArrowRight as nextIcon,
  mdiArrowDown as ascSortIcon,
  mdiArrowUp as descSortIcon
} from '@mdi/js'
import {
  covertDataTableOptionsToQueryParams,
  convertApiResponceToDataTableOptions
} from '@/api/helpers.js'

// Строки текущей страницы
const pageItems = ref([])
// Флаг активности процесса загрузки данных
const loading = ref(true)
// Общее число строк на сервере
const totalItemsCount = ref(0)

/**
 * Настройки колонок
 */
const headers = [
  {
    title: 'ID',
    align: 'end',
    sortable: true,
    key: 'id',
    width: '5em'
  },
  {
    title: 'Title',
    align: 'start',
    sortable: true,
    key: 'title',
    width: '100%'
  }
]

const options = reactive({
  page: 1, // Номер страницы
  itemsPerPage: 10, // Число строк на страницу
  sortBy: 'id', // Имя столбца для сортировки
  sortDir: 'asc' // Порядок сортировки
})

// Число страниц
const pagesCount = computed(() => Math.ceil(totalItemsCount.value / options.itemsPerPage))

// Обработка нажатия кнопки перехода на следующую страницу
function handleNextPageAction() {
  options.page++
  loadData()
}

// Обработка нажатия кнопки перехода на предыдущую страницу
function handlePreviousPageAction() {
  options.page--
  loadData()
}

// Обработка нажатия заголовка столбца для сортировки
function handleToggleSortAction(filed) {
  if (!filed.sortable) {
    return
  }

  /**
   * 3 режима сортировки: возрастание, убывание и отсутствие сортировки
   */
  const filedName = filed.key
  if (options.sortBy === filedName) {
    if (options.sortDir === 'asc') {
      options.sortDir = 'desc'
    } else {
      options.sortBy = null
    }
  } else {
    options.sortBy = filedName
    options.sortDir = 'asc'
  }

  loadData()
}

// Загрузка данных для текущей страницы с сервера
async function loadData() {
  loading.value = true

  const params = covertDataTableOptionsToQueryParams(options)
  const data = convertApiResponceToDataTableOptions(await getPosts(params))
  pageItems.value = data.items
  totalItemsCount.value = data.total

  loading.value = false
}

onMounted(loadData)
</script>

<style>
.data-table {
  width: 100%;
  border-collapse: collapse;
}

.data-table td,
.data-table th {
  border: 1px solid #ddd;
  padding: 8px;
}

.data-table tbody tr:nth-child(even) {
  background-color: #f2f2f2;
}

.data-table tbody tr:hover {
  background-color: #ddd;
}

.data-table th {
  padding-top: 12px;
  padding-bottom: 12px;
  text-align: center;
}

.data-table__row_loading {
  opacity: 0.5;
}

.data-datatable__progress td {
  padding: 0;
  position: relative;
  border: none;
}

.data-datatable__progress .progress-bar {
  position: absolute;
  left: 0;
  bottom: 0;
}

.data-table__footer-content {
  display: flex;
  align-items: center;
  justify-content: right;
}

.data-table__th-sortable {
  cursor: pointer;
}

.data-table__th-sortable .icon {
  vertical-align: middle;
}
</style>
import axios from 'axios'

export async function getPosts(params) {
  return await axios.get('https://jsonplaceholder.typicode.com/posts', { params })
}
// Преобразование данных компонента в параметры API
export function covertDataTableOptionsToQueryParams(opts) {
  const result = {
    _limit: opts.itemsPerPage,
    _page: opts.page
  }

  if (opts.sortBy) {
    result._sort = opts.sortBy
    result._order = opts.sortDir ? opts.sortDir : 'asc'
  }

  return result
}

// Преобразование результата API в данные для компонента
export function convertApiResponceToDataTableOptions(data) {
  return { items: data.data, total: data.headers['x-total-count'] }
}
<template>
  <h1>Vue3 DataTable</h1>
  <PostDataTable></PostDataTable>
</template>
<script setup>
import PostDataTable from '@/components/PostDataTable.vue'
</script>

Как можно развить компонент далее

Сейчас компонент содержит работу со страницами и сортировкой. Конечно, это нельзя назвать широкой функциональностью. Но данный пример задает базу, которую можно развивать. Можно дать возможность пользователю задавать число строк на странице, переходить к первой и последней странице. Дать возможность программисту вставлять шаблоны в ячейки таблицы. В этих шаблонах могут быть кнопки для действий с данными. Простор для развития имеется не малый.

Готовые альтернативы

Чтобы не изобретать велосипед, можно воспользоваться готовыми компонентами. Для этого можно взять какой-либо UI-фреймворк для Vue. В одной из предыдущих статьей упоминался неплохой вариант – Vuetufy. Сейчас уже развивается 3я версия этого пакета.

Дальнейшие планы

В следующей статье мы поразмышляем над тем как добавить этому компоненту возможность использовать шаблоны в ячейках. Эти шаблоны могут содержать интерактивные элементы. Например, кнопки. Мы реализуем эту возможность, чтобы попрактиковаться в использовании слотов.

Если у вас есть какие-либо вопросы, пожелания или замечания – пожалуйста, пишите.

Чтобы не пропустить очередную статью вы можете подписаться на канал.

Оставить ответ

Ваш адрес email не будет опубликован. Обязательные поля помечены *