Рекурсивное дерево категорий

Сегодня будет интересно - мы напишем рекурсивное дерево категорий, которое позволит нам:
  • создавать новые категории
  • редактировать имеющиеся
  • удалять категории
  • сортировать перетаскиванием
  • сворачивать и разворачивать
  • передавать выбранную категорию наружу другим компонентам
Мы создадим полнофункциональное дерево по типу админки MODX, только своими руками.

Готовим таблицу товаров

Создаём новый пустой компонент в admin/components/categories/tree.vue, который будет передавать v-model идентификатор категории:
<template>
    <div>Tree</div>
</template>

<script>
export default {
  name: 'CategoriesTree',
  props: {
    value: {
      type: Number,
      default: 0,
    },
  },
  computed: {
    myValue: {
      get() {
        return this.value
      },
      set(newValue) {
        this.$emit('input', newValue)
      },
    },
  },
}
</script>
Теперь его можно использовать в таблице товаров admin/pages/products.vue:
<template>
  <b-row no-gutters>
    <b-col lg="3" class="pr-lg-4">
      <categories-tree v-model="filters.category" />
    </b-col>
    <b-col lg="9">
      <vesp-table ...></vesp-table>
    </b-col>
  </b-row>
</template>

<script>
import CategoriesTree from '@/components/categories/tree.vue'

export default {
  name: 'ProductsPage',
  components: {CategoriesTree},
  // ...
  data() {
    return {
      // ...
      filters: {
        query: '',
        category: 0,
      },
    }
  },
}
</script>
Здесь мы импортируем новый компонент и добавляем его в колонку слева от таблицы.
Значение компонента будет передано в параметр filters таблицы, где его поймает наш контроллер Controllers/Admin/Products.php и отфильтрует товары:
    protected function beforeCount(Builder $c): Builder
    {
        if ($category = (int)$this->getProperty('category')) {
            $c->where(static function (Builder $c) use ($category) {
                $c->where('category_id', $category);
                $c->orWhereHas('productCategories', static function (Builder $c) use ($category) {
                    $c->where('category_id', $category);
                });
            });
        }
        // ...
    }
С таблицей товаров покончено, переходим к работе с деревом.

Дерево категорий

Загружать дерево мы будем методом fetch, потому что загрузка происходит не на странице, так что метода asyncData у нас здесь нет.
  data() {
    return {
      url: 'admin/categories',
      categories: [],
    }
  },
  async fetch() {
    try {
      const params = {parent: 0, limit: 0, sort: 'rank', dir: 'asc'}
      const {data} = await this.$axios.get(this.url, {params})
      this.total = data.total
      this.categories = this.buildTree(data.rows)
    } catch (e) {}
  },
  // ...
  methods: {
    buildTree(dataset) {
      const hashTable = {}
      dataset.forEach((item) => {
        hashTable[item.id] = {...item, children: []}
      })

      const dataTree = []
      dataset.forEach((item) => {
        if (item.parent_id && hashTable[item.parent_id]) {
          hashTable[item.parent_id].children.push(hashTable[item.id])
        } else {
          dataTree.push(hashTable[item.id])
        }
      })

      return dataTree
    },
  },
}
Метод fetch выполняется после монтирования компонента, и сохраняет данные в свойство categories через использование метода buildTree, который строит из плоского списка дерево.
Сначала мы создаём пустой объект hashTable, в который зысовываем категории с добавлением дочернего пустого массива children. Затем мы снова проходим по списку категорий и распихиваем их в новый пустой массив dataTree.
Фокус в том, что если у категории указан parent_id, то мы добавляем её в children категории внутри hashTable. А если parent_id равен 0, то это корневая категория и её мы добавляем в dataTree. Таким образом в дереве оказываются корневые категории с заполненным массивом children.
Благодаря логике работы ссылок в javascript нас не волнует очерёдность добавления дочерних и корневых директорий. В dataTree попадает ссылка на объект из hashTable, и если ему будут добавлены новые записи в children - они появятся и в dataTree.
В итоге получается дерево категорий, которое мы можем проверить в шаблоне компонента:
    <div v-for="category in categories" :key="category.id">
      <div class="pl-3">{{ $translate(category.translations) }}</div>
      <div v-if="category.children.length">
        <div v-for="subCategory in category.children" :key="subCategory.id">
          <div class="ml-5">{{ $translate(subCategory.translations) }}</div>
        </div>
      </div>
    </div>
Как видно, здесь мы отрисовываем дерево только на один уровень вглубь.
Для отрисовки рекурсивного дерева с неограниченной глубиной, нам понадобится второй компонент, он будет отображать одну категорию.

Компонент категории

Создаём новый компонент admin/components/categories/node.vue:
<template>
  <ul>
    <li>
      <div @click="onSelect">{{ $translate(node.translations) }}</div>
      <categories-node v-for="child in node.children" :key="child.id" v-model="myValue" :node="child" />
    </li>
  </ul>
</template>

<script>
export default {
  name: 'CategoriesNode',
  props: {
    value: {
      type: Number,
      default: 0,
    },
    node: {
      type: Object,
      required: true,
    },
  },
  computed: {
    myValue: {
      get() {
        return this.value
      },
      set(newValue) {
        this.$emit('input', newValue)
      },
    },
  },
  methods: {
    onSelect() {
      this.myValue = this.node.id
    },
  },
}
</script>
Этот компонент так же поддерживает v-model и будет передавать в него id категории при клике по названию. Данные категории передаются в обязательный параметр node. Как видно, компонент в шаблоне вызывает сам себя, то есть, является рекурсивным.
Остаётся вызвать его для корневых категорий в родительском компоненте CategoriesTree, и наше дерево может быть бесконечным!
<template>
  <div>
    <div class="font-weight-bold pb-3">Выбрана категория: {{ myValue }}</div>

    <categories-node v-for="category in categories" :key="category.id" v-model="myValue" :node="category" class="p-0" />
  </div>
</template>

<script>
import CategoriesNode from './node'

export default {
  name: 'CategoriesTree',
  components: {CategoriesNode},
  // ...
}
</script>
Проверяем работу:
Как видите, основной функционал уже работает. Дальше остаются всякие полезные украшательства.

Сворачивание категорий

Функцинал сворачивания и разворачивания будет работать на уровне категории, а не дерева. Для этого добавим новую переменную в компонент CategoriesNode:
  data() {
    return {
      expanded: this.node.id === this.value && this.node.children.length > 0,
    }
  },
Начальное значение будет зависеть от текущей активной категории. Для реализации сворачивания используем компонент b-collapse из нашего любимого BootstrapVue. Иконку faMinus нужно подключить в nuxt.config.js.
<template>
  <div>
    <div :class="nodeClass">
      <b-button v-if="node.children.length" variant="link" class="toggle" @click="toggleNode">
        <transition name="fade" mode="out-in">
          <fa v-if="expanded" key="minus" icon="minus" class="fa-fw" />
          <fa v-else key="plus" icon="plus" class="fa-fw" />
        </transition>
      </b-button>
      <div class="title" @click="onSelect">{{ $translate(node.translations) }}</div>
    </div>

    <b-collapse v-if="node.children.length" v-model="expanded" class="children">
      <categories-node v-for="child in node.children" :key="child.id" v-model="myValue" :node="child" />
    </b-collapse>
  </div>
</template>
Добавляем новую кнопку, которая меняет переменную expanded, и от неё уже зависят стили оформления - их вы посмотрите в репозитории. Для плавной замены иконок используется Vue transitions.
Сворачивание работает и по кнопке, и по активации категории - мы можем манипулировать свойством expanded как нам угодно. При повторном клике на активную категорию, выбор отменяется.

Действия с категориями

Теперь давайте добавим разные действия для категорий. Очевидно, нам потребуются разные запросы на сервер, и хотелось бы все такие дела держать в одном родительском компоненте CategoriesTree, раз он уже управляет загрузкой.
С добавлением новой категории никаких проблем нет - выводим модалочку с уже готовой формой и обновляем дерево после сохранения:
<template>
  <div class="categories-tree">
    <div class="pb-3 mb-3 border-bottom">
      <b-button @click="createNode">
        <fa icon="plus" class="fa-fw" /> {{ $t('actions.create') }}
      </b-button>
    </div>

    <!-- ... -->

    <vesp-modal
      v-model="record"
      :url="url"
      :visible="modalVisible"
      :update-key="updateKey"
      :title="$t('models.category.title_one')"
      @hidden="modalVisible = false"
    >
      <template #form-fields>
        <form-category v-model="record" />
      </template>
    </vesp-modal>
  </div>
</template>

<script>
export default {
  // ...
  computed: {
      // ...
      updateKey() {
        return this.url.split('/').join('-')
      },  
  },
  mounted() {
    this.$root.$on(`app::${this.updateKey}::update`, this.$fetch)
  },
  methods: {
    // ...
    createNode() {
      this.record = {
        parent_id: this.myValue,
        alias: '',
        active: true,
        translations: [],
      }
      this.modalVisible = true
    },
  }
}
</script>
Кнопка активирует модалку и заполняет запись пустыми значениями. Если в дереве уже выбрана категория, она будет подставлена в качестве родительской для создаваемой.
После создания или изменения категорий, нам нужно перезагрузить дерево. Vesp-modal был создан для редактирования записей таблиц, поэтому автоматически генерирует событие после сохранения, которое мы будем слушать. Имя события генерируется по умолчанию из адреса API, на который отправляется форма, но для надёжности мы явно указываем одинаковый updateKey и в модалке, и в событии mounted.
А еще vesp-modal по умолчанию перекидывает на предыдущую страницу после сохранения, так что нам нужно указать свою функцию на событие @hidden. Она будет переключать переменную modalVisible, чтобы мы могли постоянно открывать-закрывать модалку.
При редактировании категории мы будем использовать эту же модалку и vesp-modal самостоятельно поймёт, что в API нужно отправлять не PUT запрос, а PATCH, по наличию первичного ключа record.id в форме.
В отличие от кнопки создания, кнопки редактирования и удаления нужно пробросить в категории дерева, то есть в другой компонент. Но действия при нажатии на них нужно обрабатывать в родительском. Как это сделать?
Конечно, с помощью объявления слота actions в компоненте CategoriesNode:
<template>
  <div>
    <div :class="nodeClass">
      <!-- -->
      <div class="title" @click="onSelect">{{ $translate(node.translations) }}</div>

      <!-- Вот и наш слот, он передаёт внутрь данные категории и состояние "разёрнутости" -->
      <div class="actions">
        <slot name="actions" v-bind="{node, expanded}" />
      </div>
    </div>

    <b-collapse v-if="node.children.length" v-model="expanded" class="children">
      <categories-node v-for="child in node.children" :key="child.id" v-model="myValue" :node="child">
        <!-- Прокидывание слота вложенным категориям -->
        <template v-for="slotName in Object.keys($scopedSlots)" #[slotName]="slotProps">
          <slot :name="slotName" v-bind="slotProps" />
        </template>
      </categories-node>
    </b-collapse>
  </div>
</template>
Сам слот мы указываем после названия категории, но он будет работать только для первого уровня, на дочерние уже не сработает. Поэтому нужно пройти по встроенной в Vue переменной $scopedSlots и пробросить всё, что есть, внутрь рекурсивного вызова.
Теперь можно использовать новый слот в родительском компоненте CategoriesTree:
<template>
    <!-- -->
    <categories-node v-for="category in categories" :key="category.id" v-model="myValue" :node="category">
      <template #actions="{node}">
        <b-button variant="link" class="p-0" @click="editNode(node)">
          <b-spinner v-if="loading === node.id" small />
          <fa v-else icon="edit" class="fa-fw" />
        </b-button>
        <b-button variant="link" class="p-0 ml-1 text-danger" @click="deleteNode(node)">
          <fa icon="times" class="fa-fw" />
        </b-button>
      </template>
    </categories-node>
    <!-- -->
</template>
Вставляем в тело дочернего компонента 2 кнопки с действиями, нажатия на которые будем обрабатывать в родительских методах. Обратите внимание, что они передают в методы свойство node, полученное из слота. Благодаря этому мы и получаем данные любой категории, неважно на каком уровне вложения она находится.
Надеюсь, у вас не разболится голова от осознания происходящй магии. Лично я довольно долго в это вникал.
Пишем методы обработки:
  data() {
    return {
      // ...
      loading: false,
    }
  },
  methods: {
    async editNode(node) {
      // Врубаем индикатор загрузки
      this.loading = node.id
      try {
        // Получаем данные категории
        const {data} = await this.$axios.get(url + '/' + node.id)
        this.record = data
        // И выводим модалку
        this.modalVisible = true
      } catch (e) {
      } finally {
        // При любом разрешении запросы индикатор останавливается
        this.loading = null
      }
    },
    async deleteNode(node) {
      // Используем функционал BootstrapVue для вывода окошка подтверждения действия
      const properties = {
        title: this.$t('components.confirm_delete_title'),
        okVariant: 'danger',
        okTitle: this.$t('components.confirm_yes'),
        cancelTitle: this.$t('components.confirm_no'),
        footerClass: 'justify-content-between',
        hideHeaderClose: false,
        autoFocusButton: 'ok',
        centered: true,
      }
      const res = await this.$bvModal.msgBoxConfirm(this.$t('components.confirm_delete_message'), properties)
      // Удаляем категорию в зависимости от ответа
      if (res) {
        try {
          await this.$axios.delete(url + '/' + node.id)
          // При удалении активной категории, обнуляем значение дерева
          if (this.myValue === node.id) {
            this.myValue = 0
          }
          // И генерируем событие на обновление дерева
          this.$root.$emit(`app::${this.updateKey}::update`)
        } catch (e) {}
      }
    },
  }
Думаю, здесь всё понятно. Не забываем только подключить новый компонент b-spinner как SpinnerPlugin в nuxt.config.js для симпатичного индикатора загрузки категории при редактировании.
У меня на видео уже подключены стили оформления, у вас будет не так красиво, пока вы не скопируете их из репозитория.

Сортировка перетаскиванием

Для сортировки используем чистый Sortable JS, который у нас уже есть в проекте как зависимость для VueDraggable. Помните, мы его использовали для галереи товаров?
Такие сложности необходимы для более точной работы с рекурсивным деревом, потому что каждый уровень будет инициализироваться отдельно. Начинаем с корневых категорий в родительском компоненте:
<template>
    <!-- -->
      <div ref="roots" class="root-nodes" :data-id="0">
        <categories-node v-for="category in categories" :key="category.id" v-model="myValue" :node="category">
            <!-- -->
        </categories-node>
      </div>
    <!-- -->
</template>
<script>
import {Sortable} from 'sortablejs'
// ...
export default {
  data() {
    return {
      // ...
      sortOptions: {
        group: 'tree',
        fallbackOnBody: true,
        invertSwap: true,
        animation: 150,
      },
    },
  },
  mounted() {
    // ...
    new Sortable(this.$refs.roots.$el, {...this.sortOptions, onEnd: this.sortNodes})
  },
  methods: {
    async sortNodes({to, from, item, oldIndex, newIndex}) {
      const id = Number(item.dataset.id)
      const oldParent = Number(from.dataset.id)
      const newParent = Number(to.dataset.id)

      if (oldParent !== newParent || oldIndex !== newIndex) {
        await this.$axios.post(this.url + '/' + id, {parent: newParent, rank: newIndex})
        // Перезагружаем дерево напрямую, без события
        await this.$fetch()
      }
      // Чтобы отправить событие о сортировке нодам только после загрузки
      this.$root.$emit(`app::categories-tree::sort`)
    },
  },
// ...
}
</script>
Обратите внимание, что у элементов добавились data-id, чтобы мы могли работать с идентификаторами категорий на чистом JS.
Заворачиваем корневые категории в элемент с ref, чтобы использовать его для инициализации сортировки. Парметры сортировщика собраны в отдельной переменной, они нам еще пригодится.
При перетаскивании категории на сервер будет отправлен POST запрос для изменения rank и/или parent категории. Ловим его в контроллере категорий:
    public function post(): ResponseInterface
    {
        $key = $this->getPrimaryKey();
        $rank = (int)$this->getProperty('rank', 0);
        $parentId = (int)$this->getProperty('parent', 0);
        /** @var Category $category */
        if (!$key || !$category = Category::query()->find($key)) {
            return $this->failure('Not Found', 404);
        }
        // Если указано перемещение в нового родителя - проверяем его наличие
        if ($parentId && Category::query()->where('id', $parentId)->count()) {
            $category->parent_id = $parentId;
        } else {
            $category->parent_id = null;
        }
        // Определяем направление сортировки, категорую перенесли выше или ниже
        $dir = $category->rank > $rank ? 'desc' : 'asc';
        // Сохраняем новый ранк
        $category->rank = $rank;
        $category->save();

        // Пересортировываем остальные категории в родителе
        $rows = Category::query()->orderBy('rank')->orderBy('updated_at', $dir);
        if ($parentId) {
            $rows->where('parent_id', $parentId);
        } else {
            $rows->whereNull('parent_id');
        }
        // При пересортировке не меняем updated_at!
        /** @var Category $row */
        foreach ($rows->cursor() as $idx => $row) {
            $row->update(['rank' => $idx, 'timestamps' => false]);
        }

        return $this->success();
    }
Здесь мы меняем порядок одной категории и пересортировываем соседние так, чтобы всё было по порядку.
Это у нас сортировка корневых категорий, теперь нужно рекурсивно сортировать и дочерние, чтобы категории можно было таскать по разным уровням вложенности.
Добавляем новые свойства в дочерний компонент:
<script>
import {Sortable} from 'sortablejs'

export default {
  name: 'TreeNode',
  props: {
    // ...
    sortOptions: {
      type: Object,
      default() {
        return {}
      },
    },
    sortFunction: {
      type: Function,
      default() {},
    },
  },
  // ...
  data() {
    return {
      dragging: false,
      expanded: this.node.id === this.value && this.node.children.length > 0,
    }
  },
  mounted() {
    if (this.$refs.children) {
      new Sortable(this.$refs.children, {...this.sortOptions, onEnd: this.sortFunction})
    }
    // Слушаем событие о завершении сортировки, и сворачиваем ноду, если надо
    this.$root.$on(`app::categories-tree::sort`, () => {
      this.dragging = false
      if (this.expanded && !this.node.children.length) {
        this.expanded = false
      }
    })
  },
  methods: {
    // ...
    // Отмечаем, что эта категория сейчас перетаскивается
    onDragStart() {
      this.dragging = true
    },
    onDragEnd() {
      this.dragging = false
    },
    // Разворачиваем категорию при попытке что-то в неё перетащить 
    onDragEnter() {
      // Категория не реагирует сама на себя
      if (!this.dragging) {
        this.expanded = true
      }
    },
  }
  // ...
</script>
Указываем ref, data-id и события перетаскивания:
<div :class="{nodes: true, expanded}" @dragstart="onDragStart" @dragend="onDragEnd" @dragenter="onDragEnter">
    <!-- -->
    <b-collapse v-if="node.children.length" v-model="expanded" class="children">
      <div ref="children" class="children-nodes" :data-id="node.id">
        <categories-node
          v-for="child in node.children"
          :key="child.id"
          v-model="myValue"
          :node="child"
          :data-id="child.id"
          :sort-options="sortOptions"
          :sort-function="sortFunction"
        >
          <!-- -->
        </categories-node>
      </div>
    </b-collapse>
    <!-- -->
</div>
Обратите внимание, что data-id указывается два раза: в корневом элементе, для id родителя, и дочерним категориям. Идентификаторы получаются из разных переменных. А еще мы передаём sortOptions и sortFunction вложенным категориям.
Что осталось сделать? Правильно, передать sortOptions и sortFunction из родительского компонента в дочерние, чтобы они пошли дальше по цепочке. Одинаковый group внутри sortOptions объединяет все уровни и позволяет перетаскивать категории между ними:
<template> 
    <!-- -->
    <div ref="roots" class="root-nodes" :data-id="0">
      <categories-node
        v-for="category in categories"
        :key="category.id"
        v-model="myValue"
        :node="category"
        :data-id="category.id"
        :sort-options="sortOptions"
        :sort-function="sortNodes"
      >
<!-- -->
</template>
Самый хитрый момент здесь в том, чтобы разворачивать категории без дочерних элементов при перетаскивании. Иначе мы не сможем добавлять в них вложения. А после сортировки все ноды будут реагировать на событие app::categories-tree::sort и сворачиваться, если у них нет потомков.
На видео для наглядности я добавил вывод rank категории.

Заключение

Сортировка не идеальна, но вполне рабочая. Её, конечно, можно еще накрутить, добавить всяких проверок и условий, но эта заметка и так получилась очень объёмной. В любом случае, это отличный старт для рекурсивного дерева категорий, а дальше его можно и доработать.
Эту заметку я писал целый рабочий день, потому что нашёл и поправил несколько глюков в своём коде. Если найдёте какие-то неточности - укажите в комментариях, я поправлю. Ну и вопросы, конечно, тоже пишите!
Итоговый коммит, как обычно, в репозитории.

2 комментария

Отлично получилось! Ну и раз теперь есть дерево, теперь ещё минус один повод использовать modx)
Василий Наумкин
Очень рад!
bezumkin.ru
Personal website of Vasily Naumkin
Прямой эфир
Александр Наумов
23.07.2024, 00:20:37
Василий, спасибо большое!!
Василий Наумкин
01.07.2024, 11:56:41
Да, верно, именно так. А в контроллере, скорее всего, ловить данные методом post.
Василий Наумкин
26.06.2024, 09:38:15
О, точно, вылезает если не залогинен. Спасибо, исправил!
Василий Наумкин
09.04.2024, 04:45:01
> Ошибка 500 Это не похоже на ошибку Nginx, это скорее всего ошибка PHP - надо смотреть его логи. ...
Василий Наумкин
20.03.2024, 21:21:52
Volledig!
Андрей
14.03.2024, 13:47:10
Василий! Как всегда очень круто! Моё почтение!
russel gal
09.03.2024, 20:17:18
> А этот стоило написать хотя бы затем, чтобы получить комментарий от юзера, который ничего не писал...
Александр Наумов
27.01.2024, 03:06:18
Василий, спасибо! Извини, тупанул.
Василий Наумкин
22.01.2024, 07:43:20
Давай-давай!
Василий Наумкин
24.12.2023, 14:26:13
Спасибо!