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

Это моя первая статья, в которой я хочу поделиться своими первыми шагами в мир 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. пулл риквесты и доработки никто не запрещал...милости прошу.

Комментарии (3)


  1. darkxanter
    13.07.2022 10:28
    +1

    Вместо Vuex лучше уже использовать Pinia, он считается преемником и текущей рекомендацией.

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


    1. Green21 Автор
      13.07.2022 11:01
      +1

      Привет! Я так понял Pinia стал рекомендацией совсем недавно. Пока его не тыкал. Но в будущем почитаю о нем, спасибо!

      P.S. Да, сорян, ребят....забыл окрыть репу. Щас должно быть все норм.


      1. TAZAQ
        13.07.2022 19:01
        +1

        Ананас появился примерно в 20 году, в 21 его рекомендовали, с начала 22 это уже стандарт