Компоненты админки: vesp-modal

Следующий важный компонент админки - модальные окна для создания и редактирования моделей.
Vesp-modal расширяет компонент из BootstrapVue, принимает всего его параметры и добавляет отправку формы с данными.
Редактируемые данные передаются через v-model, и если в них указан первичный ключ (по умолчанию поле id, но можно настроить и другой), то форма отправится методом PATCH (редактирование), а если нет, то PUT (создание).
Содержимое формы вставляется в слот #form-fields, а сами формы я предлагаю хранить отдельно от модальных окон, для удобства. Давайте разберём форму работы с пользователями.

Создание модели

Модальные окна в админке находятся в отдельных маршрутах в директории pages, это даёт возможность переходить сразу по прямым ссылкам на создание и редактирование чего-либо.
Эти маршруты являются доченими по отношению к таблице с моделями, поэтому в шаблоне с таблицей обязательно должен быть тег <nuxt-child /> - именно в него и будет вставлена страница с модалкой.
Так и получается, что список пользователей находится по адресу admin/users, а создание нового пользоветеля admin/users/create.
<template>
  <!--Окну обязательно нужено передать данные и адрес для отправки-->
  <vesp-modal v-model="record" :url="url" :title="$t('models.user.title_one')">
    <!--Вставляем поля формы в соответствующий слот компонента-->
    <template #form-fields>
      <!--И синхронизируем данные между формой и модалкой-->
      <form-user v-model="record" />
    </template>
  </vesp-modal>
</template>
<script>
// Чтобы не дублировать адрес, импортируем его из страницы с таблицей
// Вдруг потом нужено будет его поменять? Проще это сделать в 1 месте
import {url} from '../users'
// Импортируем компонент с полями формы
import FormUser from '@/components/forms/user'

// Экспортируем url снова, для окна редактирования
export {url}
// Основной экспорт
export default {
  components: {FormUser},
  data() {
    return {
      // передаём url в template
      url,
      // и выставляем данные формы по умолчанию
      record: {
        fullname: '',
        password: '',
        active: true,
      },
    }
  },
}
</script>
Вся логика отправки формы находится в модальном окне, поэтому компонент <form-user> содержит только поля формы, и ничего более. Ни тега form, ни индикации загрузки - ничего.
Иначе пришлось бы дублировать функционал отправки в каждой форме, а моей задачей было наоборот, всё упростить.
Смотрим <form-user> в сокращённом виде:
<template>
  <div>
    <!--Я использую компоненты BootstrapVue, но вообще можно псиать обычные-->
    <!--div, input и прочие теги-->
    <b-row>
      <b-col md="6">
        <!--Названия полей мультиязычные по умолчанию-->
        <b-form-group :label="$t('models.user.username')">
          <!--Поля ввода редактируют присланные данные-->
          <b-form-input v-model.trim="record.username" required autofocus />
        </b-form-group>
      </b-col>
      <b-col md="6">
        ...
      </b-col>
    </b-row>
    <b-form-group :label="$t('models.user.fullname')">
      <b-form-input v-model.trim="record.fullname" required />
    </b-form-group>
   ...
  </div>
</template>
<script>
export default {
  name: 'FormUser',
  // Компонент принимает только один параметр - value
  props: {
    value: {
      type: Object,
      required: true,
    },
  },
  // И с помощью этого value мы создаём новый вычисляемый параметр
  computed: {
    // В эту логику сразу трудно въехать, смотрите ниже
    record: {
      get() {
        return this.value
      },
      set(newValue) {
        this.$emit('input', newValue)
      },
    },
  },
  ...
}
</script>
Следите за руками:
  • Данные в модальном окне передаются через v-model, внутри компонент такие данные доступны как value. Это у Vue так придумано, нужно просто запомнить
  • v-model всегда означает, что передаваемые данные должны быть реактивными, то есть когда они меняются внутри компонента, мы это видим снаружи.
Как это делается в Vue? Мы получаем данные в this.value и с их помощью создаём новое вычисляемое свойство record, у которого есть 2 метода: get и set.
Get срабатывает при обращении к полям record, а set - при изменении. Так что, напрямую данные менять нельзя, и это связано с реактивностью работы.
Вместо изменения напрямую, функция set отправляет событие об изменении, которое принимает родительский компонент, меняет данные у себя передаёт уже изменённые данные обратно через v-model.
Честно скажу, я вникал в эту логику примерно неделю, пока не привык и не понял как с этим работает. Это реально сложная парадигма, и если пока не получается понять - то просто заучите, что при передаче данных через v-model в компонент, он не меняет их сам, он только оповещает родительский компонт об изменениях, чтоб тот их изменил у себя.
Именно эта логика гарантирует синхронность данных, когда в модалке v-model используется 2 раза: для самого окна, и для формы. Потому что при изменении чего-то в форме, событие об этом улетает в модалку, та обновляет данные и новые данные выставляются обратно в обоих местах.
Более подробно можно почитать в документации Vue, а мы можем пока отправить форму на сервер. Для этого прописан специальный метод в vesp-modal:
async submit() {
  try {
    // Передаём данные в хук beforeSubmit - он должен проверить их перед отправкой
    // Перед этим убираем реактивность из данных, путём конвертации объекта в 
    // JSON и обратно
    const record = this.beforeSubmit(JSON.parse(JSON.stringify(this.record)))
    if (record) {
      // Если в ответ вместо массива пришла строка - это ошибка проверки, выводим её
      if (typeof record === 'string') {
        this.$toast.error(record)
      } else {
        // Ошибки нет, выставляем флаг загрузки
        this.loading = true
        // Определяем метод отправки: PATCH или PUT
        const method = record.id ? this.actionEdit : this.actionCreate
        const {data} = await this.$axios[method](this.url, record)
        // Даём событие об успешной отправке, на него можно подписаться
        this.$emit('after-submit', data)

        // И даём еще одно, глобальное событие - на него по умолчанию 
        // подписывается таблица с редактируемыми данными
        // Это даёт нам обновление таблицы моделей при работе с ними в модалке
        if (this.updateKey) {
          this.$root.$emit(`app::${this.updateKey}::update`, data)
        }
        // Закрываем окно
        this.hide()
      }
    }
  } catch (err) {
  } finally {
    this.loading = false
  }
},
При закрытии окна срабатывает метод, который возвращает нас на предыдущий адрес, с таблицей моделей. Как видите, всё максимально автоматизировано.

Редактирование модели

Редактирование пользователей находится по адресу admin/users/edit/idюзера и расширяет страницу создания модели.
У него вообще нет тега <template> (хотя его можно и добавить), потому что мы никак не меняем внешний вид компонента. Эта та же формы создания модели, с одним отличием - мы должны ей подсунуть уже созданную ранее модель.
<script>
// Загружаем страницу создания юзера и адрес для запросов из предыдущей модалки
import Create, {url} from '../create'

export default {
  // Указываем, что наш компонент раширяет родительский
  // То есть, он наследует всё, что прописано в UserCreate
  extends: Create,
  // Страница работает только если прислан id юзера
  validate({params}) {
    return /^\d+$/.test(params.id)
  },
  // Эта особенная функция загружает данные до создания компонента
  // и является особенностью Nuxt, как и метод validate
  async asyncData({app, params, error}) {
    try {
      // Получаем данные методом GET из контроллера
      const {data: record} = await app.$axios.get(url + '/' + params.id)
      // Возвращаем массив с данными, которые будут выставлены компоненту
      // Родительский компонент выставлял переменную record, её мы и заменяем
      return {record}
    } catch (e) {
      // Если данные не получаются - ошибка
      error({statusCode: e.statusCode, message: e.data})
    }
  },
}
</script>
Вот так просто мы унаследовали родительский компонент и добавили в него получение начальных данных модели.
Функция asyncData обращается в API и заменяет родительский массив record, указанный в UserCreate по умолчанию:
record: {
  fullname: '',
  password: '',
  active: true,
},
Таким образом форма видит начальные данные, в которых есть id модели и метод форма будет отправляться на этот id методом PATCH. Вместо создания новой модели будет отредактирована существующая.

Заключение

Эта логика выкристаллизовалась сама собой во время разработки многих админок на Vesp. С каждым разом я хотел писать всё меньше кода, поэтому определял общие моменты в работе и выносил их в родительский @vesp/frontend.
Именно таким образом и появились vesp-table и vesp-modal, которые закрывают 99% потребностей в управлении обычными моделями.
Думаю, на следующием уроке мы пробежимся по остальным компонентам админки, они гораздо проще и в основном добавляют какие-то типы ввода, которых нет в BootstrapVue, навроде выбора цвета или ввода телефонных номеров.

8 комментариев

Пока что каша в голове, но думаю когда будем создавать другие формы, то в голове начнёт проясняться)
Василий Наумкин
Да, конечно, пока что это всё просто знакомство с системой - дальше будет реальная работа.
Александр Наумов
Василий, добрый день!
Дай, пожалуйста, наводку, как сделать копирование содержимого товара для заполнения полей при создании нового товара данными копируемого товара.
Если просто скопировать папку admin/pages/products/edit и переименовать ее в copy, что еще нужно подкрутить, или я не правильно мыслю?
Василий Наумкин
Создание маршрута и окошка - да, просто скопировать edit.
Только после получения данных товара в asyncData надо убрать из него id, чтобы при сохранении был создан новый, а не изменён старый.
Александр Наумов
Василий, спасибо большое, ты наше Все!!! Разобрался, сделал.
Александр Наумов
И еще, в файле admin/pages/products/edit/_id.vue в строке 8 .test(params.id)- лишнее затесалось?
Василий Наумкин
Нет, это проверка параметра маршрута на то, что в параметре id какое-то число.
validate({params}) {
  return /^\d+$/.test(params.id)
},
Потому что, если на страницу передали не id, то запрос на сервер можно и не делать.
Александр Наумов
Спасибо!
bezumkin.ru
Личный сайт Василия Наумкина
Прямой эфир
Александр Наумов
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
Спасибо!