Превращаем HTML table в GridComponent. Часть I. Frontend

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!

Привет хабровцы и любители фронтенда!

Это моя первая статья, в которой я хочу поделиться своими первыми шагами в мир frontend разработки на VueJS. И в качестве примера для изучения я решил реализовать вариант грида со стандартным набором функционала: сортировкой, фильтрацией и пагинацией. Несмотря на то, что в интернете очень много подобных решений и у каждого есть все вышеперечисленные функции (и даже больше), думаю что реализация этого компонента позволит читателю, особенно новичку, познакомится со многими аспектами разработки на VueJS.

Данная статья описывает frontend часть, а в следующей статье будет описание backend приложения на NestJS, к которому будет обращаться наш грид за данными по REST API и делать запрос в БД с помощью TypeORM.

Предполагается, что у читателя уже имеются некоторые базовые знания по HTML, CSS и JS. Все ключевые моменты и код буду стараться подробно комментировать. А пока оставлю ссылку на гитхаб с готовым проектом. Песочница с фронтенд приложением находится здесь, но прежде чем запустить его нужно убедиться что запущен контейнер с backend приложением.

Если все пройдет успешно, то должен открыться вот такой вид
Песочница с грид-компонентом
Песочница с грид-компонентом

Для демонстрации выбрана СУБД SQLite, файл которой находится в backend приложении (test_db.sqlite). На момент написания статьи в таблице находилось 100 записей. Но почему-то иногда это количество сокращается. Видимо песочница каким-то образом влияет на это. Но это не точно.

Писать будем под Linux (Ubuntu 20.04.4 LTS, Focal Fossa), поэтому навыки обращения с этой ОС не помешают.

Для реализации нашей идеи нам понадобятся (в скобочках я напишу версии, которые стоят у меня, вы же можете скачивать самые свежие):

  • NodeJS (v10.19.0), инструкции по установке на Linux можно посмотреть здесь

  • npm (v6.14.4) — по хорошему должен идти в комплекте с NodeJS

  • Vue CLI (v4.5.17) — система для быстрой разработки на VueJS

  • огромное желание, выдержка и терпение

Ну и скачайте VS Code – в блокноте писать такое себе.

P.S> компонент корректно отображается в Google Chrome и Яндекс браузере, за остальные браузеры ничего сказать не могу.

Создание проекта

Создавать проект будем с помощью визуального интерфейса Vue CLI.

Откроем терминал и запустим команду vue ui:

Запуск vue ui в терминале
Окно терминала с выполняемой командой
Окно терминала с выполняемой командой

По адресу http://localhost:8000 должен быть доступен дашбоард Vue CLI:

Рабочий стол проекта
Визуальный интерфейс Vue CLI для работы с проектами
Визуальный интерфейс Vue CLI для работы с проектами

В левом нижнем углу перейдем в менеджер проектов:

Открываем менеджер проектов
Открытие менеджера проектов
Открытие менеджера проектов

На вкладке создания проекта выбираем директорию:

Выбор директории
Выбор директории и создание проекта
Выбор директории и создание проекта

и нажимаем "Создать новый проект здесь":

Кнопка создания проекта
Кнопка создания проекта
Кнопка создания проекта

Вводим название нашего проекта, выбираем npm в качестве менеджера пакетов, остальные параметры оставляем как на скриншоте:

Выбор названия и менеджера пакетов
Создание проекта и выбор необходимых параметров
Создание проекта и выбор необходимых параметров

На вкладке создания пресетов выбираем пункт "Вручную" и идем дальше:

Выбор пресетов
Ручной выбор функций и библиотек
Ручной выбор функций и библиотек

Чекаем следующие библиотеки и нажимаем "Далее":

Выбор библиотек и функций проекта
Выбор библиотек
Выбор библиотек
Выбор опции "Использовать файл"
Выбор опции "Использовать файл"

На следующем этапе выбираем 3-ю версию Vue (хотя вполне хватило бы и второй, но будем идти в ногу со временем) и ставим галочку напротив history mode:

Выбор версии Vue
Выбор версии Vue и history mode
Выбор версии Vue и history mode

Нажимаем "Создать проект" и отвечаем на запрос по созданию пресета (если пресет не нужен - кликаем "Продолжить без сохранения").

После этого Vue CLI начнет создавать проект. Если все пройдет успешно – можно закрывать визуальный интерфейс и останавливать сервер, т.к. он нам больше не понадобится – все остальные действия мы будем выполнять в VS Code.

Однофайловые компоненты

Перед тем как перейти к коду, поговорим немного про т.н. Single-File Components (SFC, однофайловые компоненты). Любое приложение на VueJS, каким бы сложным оно ни было, в своей основе состоит из однофайловых компонентов (файлы с расширением .vue). Данные компоненты — это краеугольный камень всего фреймворка VueJS. Каждый такой компонент стоит на трех китах содержит в себе три основные сущности мира Web — это разметка (html), стили (CSS) и поведение (JS), хотя и не обязательно должен содержать сразу все три.

Проведу аналогию. Если у вас был опыт создания десктопных приложений (например, WinForms на C#) вы наверняка использовали кнопочки (Button), текстовые поля ввода (TextBox) и другие компоненты для создания своих крутых приложений. У каждого такого компонента есть свои свойства и события. Свойства позволяют задавать визуальное оформление и определенное содержание, которое необходимо для дальнейшей работы этого компонента. Например, у кнопки есть такие свойства как Width, Height, Text и Name, которые задают визуальный стиль и идентифицируют кнопку среди других компонентов. Помимо этого, у кнопки есть события (например, Click), которые генерируются (файрятся, эмитятся) при определенных действиях пользователя.

Так вот, когда мы создаем однофайловый компонент во Vue, то кроме вышеупомянутых шаблона, стиля и поведения мы также можем создавать свойства этого компонента и определять события, которые будут генерироваться на определенные действия пользователя и/или на события других компонентов. При этом события зачастую содержат в себе аргументы, которые передаются вместе с этим событием.

Предварительные настройки

Откроем нашу папку с проектом в VS Code. Сразу после создания проекта его структура должна выглядеть примерно следующим образом:

Структура проекта после его создания

В папке node_modules хранятся все зависимости, поэтому ее я не раскрывал. Слегка подредактируем файл package.json, чтобы сразу после запуска у нас открывалось окно браузера.

Редактирование команды для запуска приложения
Добавляем опцию --open
Добавляем опцию --open

Открываем новый терминал и запускаем наше приложение с помощью команды npm run serve:

Запуск приложения
Запуск приложения
Запуск приложения

Если все прошло успешно — по адресу http://localhost:8080/ должно быть доступно запущенное приложение:

Окно браузера с запущенным приложением

Благодаря такой технологии как Hot Reload, которая доступна из коробки во Vue CLI, нам не обязательно останавливать запущенный сервер, — все изменения будут тут же отображаться в окне браузера, что очень сильно упрощает и ускоряет процесс разработки.

Создание компонента InputComponent

Сразу оставлю ссылку на Vue SFC Playground.

Создадим в папке public папку img, а в ней папку icons. В папке icons создадим файл clear-icon.svg и наполним его следующим содержимым:

public/img/icons/clear-icon.svg
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
 width="1000pt" height="1280.000000pt" viewBox="0 0 1280.000000 1280.000000"
>
<g transform="translate(0.000000,1280.000000) scale(0.100000,-0.100000)"
fill="#979799" stroke="none">
<path d="M1545 12784 c-85 -19 -167 -51 -243 -95 -69 -41 -1089 -1049 -1157
-1144 -101 -141 -140 -263 -140 -440 0 -169 36 -293 125 -427 29 -43 705 -726
2149 -2170 l2106 -2108 -2111 -2112 c-1356 -1358 -2124 -2133 -2147 -2169 -88
-137 -121 -249 -121 -419 -1 -181 37 -302 139 -445 68 -95 1088 -1103 1157
-1144 273 -159 604 -143 853 42 22 17 986 976 2143 2131 l2102 2101 2103
-2101 c1156 -1155 2120 -2114 2142 -2131 69 -51 130 -82 224 -113 208 -70 431
-44 629 71 69 41 1089 1049 1157 1144 101 141 140 263 140 440 0 166 -36 290
-121 422 -25 39 -746 767 -2148 2171 l-2111 2112 2107 2108 c2207 2208 2162
2161 2219 2303 75 187 77 392 4 572 -53 132 -74 157 -615 700 -289 291 -552
548 -585 572 -141 101 -263 140 -440 140 -166 0 -289 -35 -420 -120 -41 -26
-724 -702 -2172 -2149 l-2113 -2111 -2112 2111 c-1454 1452 -2132 2123 -2173
2150 -64 41 -149 78 -230 101 -79 22 -258 26 -340 7z"/>
</g>
</svg>

Это будет иконка с крестиком в формате svg для нашего инпута.

Далее создаем в папке src/components файл InputComponent.vue и наполним его такими данными:

InputComponent.vue
<template>
  <div id="container">
    <table>
      <tbody>
        <tr>
          <td>
            <input
              v-model="inputValue"
              ref="input"
              :type="inputType"
              @keyup.enter="changeInputValue"
            />
          </td>
          <td id="clear-icon-container">
            <img
              v-if="inputValue"
              id="clear-icon"
              src="../../public/img/icons\clear-icon.svg"
              alt="clear"
              @click="clearInputValue"
            />
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: "InputComponent",

  emits: ["inputValueChanged", "clearButtonClicked"],

  props: {
    inputType: String,
  },

  data() {
    return {
      inputValue: "",
    };
  },

  methods: {
    clearInputValue() {
      this.inputValue = "";
      this.$refs.input.focus();
      this.$emit("clearButtonClicked");
    },
    changeInputValue() {
      this.$emit("inputValueChanged", this.inputValue);
    },
  },
};
</script>

<style scoped>
table {
  /* border: 1px solid black; */
  width: -webkit-fill-available;
}

#clear-icon {
  width: 0.7em;
  height: 0.7em;
  cursor: pointer;
}

input {
  border: none;
  outline: none;
  width: -webkit-fill-available;
}

#clear-icon-container {
  width: 1em;
  height: 1em;
  text-align: center;
  /* border: 1px solid; */
}
</style>

В качестве шаблона для компонента использован обычный <table> с одной строкой и двумя столбцами (ячейками) помещенный в тэг <div> (вообще не обязательно оборачивать все содержимое шаблона в div, но лучше придерживаться данного стиля, чтобы избежать подобной ошибки).

В первой ячейке содержится стандартный html инпут:

<td>
    <input
       v-model="inputValue"
       ref="input"
       :type="inputType"
        @keyup.enter="changeInputValue"
     />
</td>

а во второй — svg иконка, о которой говорилось выше:

<td id="clear-icon-container">
    <img
       v-if="inputValue"
       id="clear-icon"
       src="../../public/img/icons\clear-icon.svg"
       alt="clear"
       @click="clearInputValue"
    />
</td>

Начнем с иконки. Она будет видима только в том случае, если значение inputValue не является пустым, это делается с помощью директивы v-if (условная отрисовка).

А с помощью этой строки

@click="clearInputValue"

мы обрабатываем событие щелчка по иконке с крестиком. Логика обработчика находится в методе clearInputValue():

clearInputValue() {
      this.inputValue = ""; // После щелчка мы затираем значение инпута
      this.$refs.input.focus(); // устанавливаем фокус на инпут
      this.$emit("clearButtonClicked"); // и генерируем событие clearButtonClicked
}

Чтобы в методе сослаться на элемент DOM внутри шаблона компонента — используется атрибут ref (кстати, в официальной документации как раз и описан кейс с установкой фокуса ввода).

Еще один метод этого компонента — changeInputValue():

changeInputValue() {
      this.$emit("inputValueChanged", this.inputValue);
}

это обработчик события клавиатуры keyup.enter. После нажатия клавиши Enter файрится событие inputValueChanged в аргументы которого передается значение инпута.

Теперь немного о первой ячейке с самим инпутом. В ней используется такая классная директива как v-model которая позволяет осуществлять двустороннюю привязку данных и еще много чего интересного. Этой директиве можно посвятить целую статью, но это не является целью данной работы. В нашем компоненте эта директива осуществляет двустороннюю привязку, используя inputValue, которое находится в опции data():

data() {
    return {
      inputValue: "",
    };
}
Еще немного о v-model

Вообще, данная директива работает не только с элементом <input>, но и с другими элементами ввода данных (textarea, checkbox, radio, select) и призвана заменить вот такую часто используемую операцию:

Привязка значения (value) инпута к переменной text и обработка события input
Привязка значения (value) инпута к переменной text и обработка события input

вот такой единственной строчкой:

Двустороння привязка с помощью v-model
Двустороння привязка с помощью v-model

Список свойств и событий, с которыми работает данная директива для каждого элемента ввода можно посмотреть в официальной документации. Но иногда требуется прослушивать и другие события пользователя, как, например, нажатие клавиши Enter, — при этом директиву v-model можно оставить.

У данного компонента одно единственное свойство — inputType типа String, которое будет задаваться извне (в нашем случае его будет устанавливать FilterComponent, о котором пойдет речь дальше):

props: {
    inputType: String,
}

это свойство привязывается к свойству type инпута в следующей строчке шаблона:

:type="inputType"

и рассчитано на то, чтобы принимать одно из двух значений — "Text" или "Number". И тогда наш инпут будет, соответственно, либо текстовым полем ввода, либо числовым. Вся остальная логика при этом никак не меняется.

Вот, в принципе, и все, что касается данного компонента. Если подытожить — у него есть единственное свойство inputType, которое определяет его тип и два события, которые генерируются при изменении значения inputValue и щелчке на иконку с крестиком:

emits: ["inputValueChanged", "clearButtonClicked"]

Кратко о стиле этого компонента. Все стили заключены в тэг <style> с атрибутом scoped. Этот атрибут говорит о том, что данные стили будут применяться непосредственно к элементам этого компонента и не будут влиять на стили дочерних компонентов.

Таблица по ширине будет занимать все возможное пространство родительского элемента:

table {
  /* border: 1px solid black; */
  width: -webkit-fill-available;
}

У инпута удаляем границу и аутлайн, т.к. они нам не понадобятся:

input {
  border: none;
  outline: none;
  width: -webkit-fill-available;
}

Компоненты ввода с чекбоксом и датой

Ссылки на песочницы: чекбокс, дата

Помимо стандартного поля ввода для текстовых и числовых значений создадим отдельно два компонента в папке src/components с соответствующим содержимым:

CheckboxInputComponent.vue
<template>
  <div id="container">
    <table>
      <tbody>
        <tr>
          <td>
            <input
              v-model="inputValue"
              type="checkbox"
              :indeterminate="isIndeterminate"
              @change="changeInputValue"
            />
          </td>
          <td id="clear-icon-container">
            <img
              v-if="!isIndeterminate"
              id="clear-icon"
              src="../../public/img/icons\clear-icon.svg"
              alt="clear"
              @click="clearInputValue"
            />
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: "CheckboxInputComponent",

  emits: ["inputValueChanged", "clearButtonClicked"],

  data() {
    return {
      inputValue: false,
      isIndeterminate: true,
    };
  },

  methods: {
    clearInputValue() {
      this.isIndeterminate = true;
      this.$emit("clearButtonClicked");
    },
    changeInputValue() {
      this.isIndeterminate = false;
      this.$emit("inputValueChanged", this.inputValue);
    },
  },
};
</script>

<style scoped>
table {
  /* border: 1px solid black; */
  width: -webkit-fill-available;
}

#clear-icon {
  width: 0.7em;
  height: 0.7em;
  cursor: pointer;
}

input {
  border: none;
  outline: none;
}

#clear-icon-container {
  width: 1em;
  height: 1em;
  text-align: center;
  /* border: 1px solid; */
}
</style>

DateTimeInputComponent.vue
<template>
  <div id="container">
    <table>
      <tbody>
        <tr>
          <td>
            <input
              v-model="inputValue"
              ref="input"
              type="datetime-local"
              @change="changeInputValue"
            />
          </td>
          <td id="clear-icon-container">
            <img
              v-if="inputValue"
              id="clear-icon"
              src="../../public/img/icons\clear-icon.svg"
              alt="clear"
              @click="clearInputValue"
            />
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: "DateTimeInputComponent",

  emits: ["inputValueChanged", "clearButtonClicked"],

  data() {
    return {
      inputValue: "",
    };
  },

  methods: {
    clearInputValue() {
      this.inputValue = "";
      this.$refs.input.focus();
      this.$emit("clearButtonClicked");
    },
    changeInputValue() {
      this.$emit("inputValueChanged", this.inputValue);
    },
  },
};
</script>

<style scoped>
table {
  /* border: 1px solid black; */
  width: -webkit-fill-available;
}

#clear-icon {
  width: 0.7em;
  height: 0.7em;
  cursor: pointer;
}

input {
  border: none;
  outline: none;
  width: -webkit-fill-available;
}

/* #clear-icon:hover {
  background-color: bisque;
} */

#clear-icon-container {
  width: 1em;
  height: 1em;
  text-align: center;
  /* border: 1px solid; */
}
</style>

Здесь я лишь опишу ключевые отличия данных компонентов от предыдущего.

В CheckboxInputComponent фигурирует такой атрибут как indeterminate, который позволяет перевести чекбокс в "неопределенное" состояние, отличное от true / false. Это нам понадобится в дальнейшем при формирования объекта фильтрации для его передачи на backend.

DateTimeInputComponent практически аналогичен InputComponent.

В этих компонентах вместо события нажатия клавиши Enter мы следим за событием change:

@change="changeInputValue"

Также здесь отсутствует опция props со свойством inputType, так как эти компоненты имеют целевое назначение — для ввода булевого значения и даты/времени, соответственно.

У читателя, знакомого с таким принципом ООП как наследование, наверняка возник вопрос — "А нельзя ли было создать базовый компонент и наследоваться от него, чтобы сэкономить на коде?".

Разумеется, я не первый задался этим вопросом. Для реализации наследования во Vue есть такие интересные вещи как provide, inject, mixins и extends. Но для своего проекта я все-таки оставлю код таким, какой он есть и не буду нагромождать его новыми страшными словами. Понятно, что в сложных и больших проектах без наследования не обойтись, поэтому оправляю читателя к официальной документации по ссылкам, приведенным выше.

Еще один вариант избежать дублирования кода и сделать один компонент для всех типов ввода — попробовать создать свою кастомную директиву взамен директивы v-model, поведение которой будет зависеть от параметра inputType, однако даже в официальной документации не рекомендуют использовать кастомные директивы (либо в очень крайних случаях):

Централизованное хранилище данных. Vuex

В любом языке программирования злоупотребление глобальными переменными является не очень хорошей затеей. Я думаю, поэтому создатели Vuex и придумали эту великолепную библиотеку (да что уж там, целый паттерн!), чтобы иметь глобальный доступ к переменным и методам из любого участка приложения. Помимо официальной документации и еще с десяток статей (одна из которых на хабре) советую все-таки в начале посмотреть видео одного крутого парня в мире фронтенд-разработки.

А теперь к практике! Для чего нам мог понадобится Vuex?!

Vuex нам понадобится для того, чтобы определить два глобальных объекта (state):

  1. для хранения данных, связанных с сортировкой, фильтрацией и пагинацией, чтобы на основе этих данных сформировать тело запроса на бэкенд;

  2. для хранения общих (преимущественно стилевых) настроек грид компонента (список столбцов и их свойств, пэддинги, маргины и т.п.);

Создадим в папке store папку modules, а в ней два файла grid.js и gridOptions.js (вы можете придумать названия и получше =)), которые и будут хранить два состояния (объекты state) упомянутые выше.

Первый state (в модуле grid.js) будет выглядеть следующим образом:

state для реализации сортировки, фильтрации и пагинации:
state: {
        dataGridRows: [], // массив строк, получаемых от бэкенда
        dataGridRowsCount: null, // количество возвращаемых строк
        dataSourceUri: "http://127.0.0.1:3000/documents/findPaginated", // адрес, куда мы будем ходить за данными
        sorting: {
            sortColumn: "createdOn", // название столбца для сортировки
            sortDirection: "DESC", // направление сортировки
        },
        pagingData: {
            pageSize: 5, // количество строк на странице
            pageNumber: 1, // текущий номер страницы
        },
        filter: {} // объект с фильтрами
}

также сюда добавлен массив для хранения строк, возвращаемых бэкендом и их количество (да, выглядит странно, т.к. кол-во можно легко вычислить как число элементов массива, но забегая наперед скажу, что TypeORM, которая будет использоваться для запросов к БД, помимо строк возвращает в отдельном параметре и их количество, поэтому воспользуемся этим). И для удобства поместим сюда также URI backend-приложения.

А вот реальный пример итогового тела POST-запроса для отправки на бэкенд в формате JSON:

{
    "paging": {
        "skip": 0,
        "take": 5
    },
    "sorting": {
        "createdOn": "DESC"
    },
    "filtering": {
        "createdOn": {
            "columnType": "datetime-local",
            "comparisonOperator": "greaterThanOrEqualOperator",
            "valueToFilter": "2000-06-19T13:39"
        },
        "inArchive": {
            "columnType": "checkbox",
            "comparisonOperator": "equalOperator",
            "valueToFilter": true
        },
        "id": {
            "columnType": "number",
            "comparisonOperator": "greaterThanOperator",
            "valueToFilter": 10
        }
    }
}

Кратко о свойствах. Объект paging содержит два свойства — skip и take, которые определяют сколько строк "пропускать" и сколько "брать", соответственно. Они вычисляются нехитрым способом на основе объекта pagingData, находящемся в state. В данном функционале предусмотрено, что сортировка будет выполняться только по какому-либо одному столбцу. Название этого столбца и направление сортировки находятся в объекте sorting. Объект filtering содержит в себе данные о фильтрации — список столбцов, их типы, операторы сравнения и значения для фильтрации.

Экшн fetchDataGridRows выполняет асинхронный POST-запрос на бэкенд для получения данных из БД используя популярную библиотеку axios. Также стоит упомянуть про мутацию addNewColumnDataToFilter, которая накапливает фильтры, устанавливаемые пользователем. Остальные мутации и геттеры в файле grid.js предназначены для изменения и получения свойств объекта state, соответственно, и не требуют особых пояснений.

Второй глобальный state, о котором шла речь выше, находится в модуле gridOptions.js. Помимо state этот файл содержит лишь геттеры для получения свойств состояния и их последующей передачи в грид компонент для его настройки.

Пагинация

Компонент для пагинации состоит из 7 составляющих:

Пагинация для грид-компонента
Пагинация для грид-компонента
  1. Селектор с предзаполненным списком количества строк на странице;

  2. Блок с общим количеством строк в гриде;

  3. Кнопка перехода на первую страницу;

  4. Кнопка перехода на предыдущую страницу;

  5. Текстовое поле с текущим номером страницы и возможностью его редактирования;

  6. Кнопка с общим количеством страниц (по клику - переход на последнюю страницу);

  7. Кнопка с переходом на следующую страницу;

Все пункты кроме второго изменяют состояние этого компонента, поэтому чтобы оповестить грид о переходах по страницам (пункты 3, 4, 5, 6, 7), об изменение кол-ва строк на странице (1) после любого изменения мы будем генерировать событие pagingDataChanged:

emits: ["pagingDataChanged"]

В этом компоненте используется новая опция — computed. Для тех, кто программировал на C# — это аналог свойств. Computed свойства вычисляются на основе данных в опции data. При этом мы также можем использовать геттеры и сеттеры для осуществления двусторонней привязки с помощью директивы v-model.

Самым "сложным" computed-свойством здесь является totalPageCount(), которое вычисляет итоговое количество страниц для кнопки (п.6) на основе двух параметров — количества загруженных строк в гриде this.$store.getters.dataGridRowsCount и количества строк на странице this.$store.getters.pageSize. А эти параметры, в свою очередь, хранятся в глобальном состоянии (state), о котором шла речь в предыдущем разделе.

Помимо computed свойств в этом компоненте используются методы, с помощью которых осуществляется навигация по страницам. Чем методы отличаются от свойств можно почитать тут.

FilterComponent

Добавим в папку с компонентами файл FilterComponent.vue.

Данный компонент будет состоять из выпадающего списка операторов сравнения и компонента ввода, о котором шла речь выше, тип которого будет зависеть от типа столбца (числовой, текстовый, дата и т.д.):

Фильтр-компонент. 1 - выпадающий список с предопределенными операторами сравнения. 2 - компонент ввода (InputComponent, в данном случае типа number).
Фильтр-компонент. 1 - выпадающий список с предопределенными операторами сравнения. 2 - компонент ввода (InputComponent, в данном случае типа number).

Данный компонент состоит из других компонентов, поэтому нужно это указать:

 components: {
    InputComponent,
    CheckboxInputComponent,
    DateTimeInputComponent,
 }

имеет два свойства:

props: {
    columnType: String,
    columnName: String,
}

и генерит единственное событие:

emits: ["filterChanged"]

Метод defaultComparisonOperatorByColumnType(columnType) определяет оператор сравнения, который будет стоять в момент загрузки грида по умолчанию в зависимости от типа столбца. А метод getComparisonOperatorsByColumnType(columnType) формирует список операторов для каждого типа столбца.

Метод commitFilterAndEmit() вызывается после любого изменения, — он формирует объект с данными для фильтрации, коммитит мутацию по добавлению фильтров в store:

this.$store.commit("addNewColumnDataToFilter", columnData);

и эмитит событие изменения фильтра:

this.$emit("filterChanged");

GridComponent

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

Грид во всей его красе
Грид во всей его красе

Грид состоит из нескольких блоков:

  1. Хедер c заголовками столбцов и чекбоксом "Выделить все" в левой части;

  2. Строка с фильтрами;

  3. Основная область с данными;

  4. Пагинация;

Возможность фильтрации, пагинацию и мультистрочное выделение можно отключить с помощью соответствующих глобальных настроек грид-компонента в файле gridOptions.js:

 behaviorOptions: {
            allowMultipleSelection: true,
            allowSelectAll: true,
            allowPaging: true,
            allowFiltering: true
 }

В качестве шаблона грид-компонента выступает стандартная html таблица. Для стилизации этой таблицы используется возможность привязки атрибута style к JS-объектам. Например, на второй строчке стили для <table> привязываются через computed-свойство getCommonTableOptions:

<table :style="getCommonTableOptions">

Данное свойство (и ему подобные) имеет одну интересную особенность — его тело находится во Vuex-модуле в разделе getters:

getCommonTableOptions(state) {
            let commonTableOptions = [];
            Array.prototype.push.apply(commonTableOptions,
                [state.commonTableOptions.borders.top,
                state.commonTableOptions.borders.right,
                state.commonTableOptions.borders.bottom,
                state.commonTableOptions.borders.left,
                state.commonTableOptions.collapse]
            )
            return commonTableOptions;
}

где в массив commonTableOptions накапливаются стили из state, а чтобы использовать этот геттер в файле компонента используется такая фича Vuex как mapGetters, которая позволяет смаппить геттеры Vuex в computed-свойства компонента. Большая часть таких стилевых свойств в коде компонента маппится именно с помощью mapGetters.

Грид генерирует только одно событие — selectionChanged:

emits: ["selectionChanged"]

которое файрится при изменении выделенных строк (либо одной строки) грида. Такая фича возможна благодаря уже знакомой директиве v-model и привязке к ней массива selectedRows:

<input
            type="checkbox"
            :value="row.id"
            v-model="selectedRows"
            @change="$emit('selectionChanged', selectedRows)"
/>

однако в массив selectedRows будут сохраняться не сами строки, а только значения столбца id, о чем говорит строчка:

:value="row.id"

обычно для большинства практических целей этого более чем достаточно — остальные значения строки можно найти по этому уникальному id.

После сортировки, фильтрации и пагинации (короче говоря, после любых действий пользователя, которые могут изменить табличные данные в гриде) диспатчится метод fetchDataGridRows

this.$store.dispatch("fetchDataGridRows");

о котором говорилось выше. Этот метод посылает запрос на бэкенд для получения данных из БД согласно фильтрам, сортировке и пагинации.

Еще немного о компонентах

Немного авторских мыслей. Как было сказано выше — основа фреймворка VueJS — это однофайловые компоненты. Они выступают в роли строительных блоков всего приложения. Однако слово компонент, лично у меня, ассоциируется именно с элементом управления (кнопка, комбобокс, грид и т.п.). Но ведь нужно где-то хранить макеты сайтов, лейауты и т.д., а это уже сложно назвать компонентами. Например, в таких паттернах как MVVM или MVC существует четкое разделение концепций. Там для отрисовки интерфейса используется представление (View), которое отвечает только за отображение данных и не участвует в логике работы приложения.

Так вот, к чему я это все...во Vue отсутствует эта строгая концепция разделения. Здесь компоненты — это наше ВСЕ! И это, наверное, главная особенность фреймворка VueJS. Любой файл с расширением .vue считается однофайловым компонентом. Поэтому одна из задач хорошего фронтендера при разработке больших приложений/сайтов/систем на Vue — это хорошо понимать и эффективно отделять компоненты (самопальные кнопки, выпадающие списки, гриды и т.д.) от компонентов-представлений (вьюхи, макеты, лейауты). Обычно первые хранятся в паке src/components, а для компонентов-представлений создают отдельную папку src/views. Хотя по сути — это все однофайловые компоненты.

В нашем небольшом приложении компоненты общаются между собой в основном посредством свойств и событий (props/emits), а также глобального store. В этой статье можно ознакомиться с другими способами взаимодействия компонентов.

Для демонстрации грида был добавлен компонент GridComponentView.vue с необходимыми мутациями.

Заключение

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

Всем добра и качественного кода!

P.S. пулл риквесты и доработки никто не запрещал...милости прошу.

Источник: https://habr.com/ru/post/676622/


Интересные статьи

Интересные статьи

Перед новым годом по мере нарастания стресса на работе я стал проводить много времени в Твиттере. Это было моё последнее пристанище после почти годовой деактивации Фейсбука, ВКонтакте и Инстаграма. Ав...
Это третий блог из серии публикаций о тестировании контрактов  потребителей сервиса. Я представил концепцию в первом блоге. Второй блог посвящен написанию тестов с использованием Pact для синхрон...
FYI: Первая часть. Бенчмарк пакетного конвейера Пакетный конвейер обрабатывает конечный объём сохранённых данных. Здесь нет потока результатов обработки, выходные данные агрегир...
Продолжаем нашу подборку интересных материалов (1, 2, 3, 4, 5). На этот раз предлагаем послушать курс о введении в анализ данных и новый выпуск ток-шоу для айтишников «Oh, my code...
Те, кто собираются открывать интернет-магазин, предварительно начитавшись в интернете о важности уникального контента, о фильтрах, накладываемых поисковиками за копирование материалов с других ресурсо...