[Перевод] Веб-приложение на Node и Vue, часть 3: развитие клиента и сервера

Сегодня публикуем третью часть из серии материалов, посвящённой разработке приложения Budget Manager с использованием Node.js, Vue.js и MongoDB. В первой и второй частях мы создавали сервер, настраивали механизмы аутентификации и занимались обустройством фронтенда. В этом материале продолжим работать над клиентской и серверной частями системы. То, что уже создано, пока почти не касается логики самого приложения, которое предназначено для работы с финансовыми документами. Поэтому, кроме прочего, мы займёмся и этим аспектом проекта.

o4cwk2hlct12t4kb6ti0qhinan8.jpeg

Несколько исправлений


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

Поэтому сегодня мы начнём с замены фонового изображения, используемого в проекте, на это (не забудьте уменьшить изображение, если вы собираетесь разворачивать приложение). Если вы хотите сразу использовать уменьшенное изображение — можете взять его из моего репозитория.

После загрузки изображения перейдём к файлу компонента App.vue и заменим то изображение, что было раньше. Кроме того, отредактируем стили:


Тут мы добавили свойство background-size: cover и следующую конструкцию:

.application {
  background: none;
}


Сделано это из-за того, что Vuetify использует белый фон для страниц приложения. Теперь, всё ещё находясь в файле App.vue, выполним некоторые изменения шаблона:


Тут мы поменяли div id="app" на a v-app, это — главный компонент из Vuetify.

Теперь откроем файл компонента Authentication.vue и внесём некоторые изменения в стили:


Здесь мы переопределили несколько стилей Vuetify, причина этого — в особенностях работы v-app. Кроме того, мы расширили класс l-auth, так как наш класс l-signup в точности такой же, различия заключаются лишь в анимации. В результате приложение будет выглядеть так:

2929da180c66e5217133b9fcd9013f7a.png

Теперь переходим к файлу index.js, который расположен в папке Authentication. Для начала внесём изменения в метод authenticate:

authenticate (context, credentials, redirect) {
    Axios.post(`${BudgetManagerAPI}/api/v1/auth`, credentials)
        .then(({data}) => {
          context.$cookie.set('token', data.token, '1D')
          context.$cookie.set('user_id', data.user._id, '1D')
          context.validLogin = true

          this.user.authenticated = true

          if (redirect) router.push(redirect)
        }).catch(({response: {data}}) => {
          context.snackbar = true
          context.message = data.message
        })
  },


Тут мы изменили промис таким образом, чтобы, разобрав объект data, извлечь из него идентификатор пользователя, так как мы намереваемся хранить этот id.

Далее, отредактируем метод signup:

signup (context, credentials, redirect) {
    Axios.post(`${BudgetManagerAPI}/api/v1/signup`, credentials)
        .then(() => {
          context.validSignUp = true

          this.authenticate(context, credentials, redirect)
        }).catch(({response: {data}}) => {
          context.snackbar = true
          context.message = data.message
        })
  },


Первый промис мы заменили стрелочной функцией, так как ответа от POST-запроса мы не получаем. Кроме того, тут мы больше не задаём токен. Вместо этого вызываем метод authenticate.

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

Теперь, сразу под методом signup, добавляем метод signout:

signout (context, redirect) {
    context.$cookie.delete('token')
    context.$cookie.delete('user_id')
    this.user.authenticated = false

    if (redirect) router.push(redirect)
},


Далее, сразу после метода signout внесём небольшие изменения в метод checkAuthentication:

checkAuthentication () {
    const token = document.cookie
    this.user.authenticated = !!token
},


Тут можно оставить всё как есть, либо, для преобразования константы token в логическое значение, воспользоваться тернарным оператором сравнения.

Распространённый недочёт JS-кода заключается в использовании логических выражений для приведения неких значений к логическому типу вместо применения конструкции с восклицательным знаком. Обычно этот вариант выглядит так:

this.user.authenticated = token ? true : false


Разработка компонента Header


Прежде чем заняться компонентом домашней страницы, создадим шапку для неё. Для этого перейдём в папку components и создадим файл Header.vue:






Сейчас перед нами довольно простая заготовка компонента. Тут имеется лишь поле для ввода поискового запроса, привязанное к данным из search, кнопка для перехода к странице клиентов, которой мы займёмся позже, переключатель для фильтрации документов и кнопка для выхода из системы.

Откроем частичный шаблон _variables, добавим туда сведения о цвете, а так же установим прозрачность background-color в значение 0.7:

// Colors
$background-tint: #1734C1;
$background-color: rgba(0, 0, 0, .7);
$border-color-input: rgba(255, 255, 255, 0.42);


Теперь определим компоненты в маршрутизаторе. Для этого откроем файл index.js в папке router и приведём его к такому виду:

// Pages
import Home from '@/components/pages/Home'
import Authentication from '@/components/pages/Authentication/Authentication'

// Global components
import Header from '@/components/Header'

// Register components
Vue.component('app-header', Header)

Vue.use(Router)


Тут мы сначала импортируем компонент Home, затем — Header, после чего регистрируем его, помня о том, что знак @ при использовании webpack является псевдонимом для папки src. App-header — это имя тега, который мы будем использовать для вывода компонента Header.

В том, что касается имён тегов, хотелось бы привести выдержку из документации по Vue.js:

Обратите внимание, что Vue не требует соблюдения правил W3C для пользовательских имён тегов (таких как требования использования только нижнего регистра и применения дефисов), хотя следование этим соглашениям считается хорошей практикой.

Теперь настал черёд маршрутизатора:

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      components: {
        default: Home,
        header: Header
      },
      meta: {
        requiredAuth: true
      }
    },
    {
      path: '/login',
      name: 'Authentication',
      component: Authentication
    }
  ]
})


Здесь мы указываем на то, что компонентом по умолчанию для домашней страницы является Home, а также включаем в эту страницу компонент Header. Обратите внимание на то, что тут мы не вносим никаких изменений в маршрут входа в систему. Компонент Header, представляющий шапку страницы, нам там не нужен.

Мы займёмся компонентом Header позже, но на данном этапе работы нас устроит его нынешнее состояние.

Разработка компонента Home


Как обычно — откроем файл компонента, которым собираемся заниматься. Для этого надо перейти в папку pages и открыть файл Home.vue:






Тут мы выводим заголовок, представленный тегом h4, содержащий название приложения. Ему назначены следующие классы:

  • white--text: используется для окрашивания текста в белый цвет.
  • text-xs-center: используется для центровки текста по оси x.
  • my-0: используется для установки полей по оси y в 0.


Тут применяется компонент budget-list, который мы создадим ниже. Он включает в себя компоненты budget-list-header и budget-list-body, которые играют роль слотов для размещения данных.

Кроме того, мы, в качестве свойств, передаём в budget-list-body массив финансовых документов budgets, данные из которого извлекаются при монтировании компонента. Мы передаём заголовок Authorization, что даёт нам возможность работать с API. Так же тут передаётся, как параметр, user_id, что даёт возможность указать то, какой именно пользователь запрашивает данные.

Разработка компонентов для работы со списком документов


Перейдём в папку components и создадим в ней новую папку Budget. Внутри этой папки создадим файл компонента BudgetListHeader.vue:






Это — просто шапка для страницы списка документов.

Теперь, в той же папке, создадим ещё один файл компонента и дадим ему имя BudgetListBody.vue:






Здесь мы описываем тело страницы, и то, как оно будет выглядеть в различных средах, причём, ориентируемся мы на мобильные устройства.

Теперь, наконец, создадим в той же папке файл BudgetList.vue и добавим в него код соответствующего компонента:




Обратите внимание на теги slot. В них мы выводим компоненты. Эти теги называются именованными слотами.

Теперь нужно добавить компонент BudgetList в маршрутизатор:

// ...

// Global components
import Header from '@/components/Header'
import BudgetList from '@/components/Budget/BudgetList'

// Register components
Vue.component('app-header', Header)
Vue.component('budget-list', BudgetList)

// ...

const router = new Router({
  routes: [
    {
      path: '/',
      name: 'Home',
      components: {
        default: Home,
        header: Header,
        budgetList: BudgetList
      },
      meta: {
        requiredAuth: true
      }
    },
    {
      path: '/login',
      name: 'Authentication',
      component: Authentication
    }
  ]
})

// ...

export default router


Как и прежде, тут мы импортируем компоненты, регистрируем их и даём возможность компоненту Home их использовать.

Доработка RESTful API


Вернёмся к серверной части проекта, поработаем над API. Для начала — немного его почистим. Для этого откроем файл user.js из папки services/BudgetManagerAPI/app/api и приведём его к такому виду:

const mongoose = require('mongoose');

const api = {};

api.signup = (User) => (req, res) => {
  if (!req.body.username || !req.body.password) res.json({ success: false, message: 'Please, pass an username and password.' });
  else {
    const user = new User({
      username: req.body.username,
      password: req.body.password
    });

    user.save(error => {
      if (error) return res.status(400).json({ success: false, message: 'Username already exists.' });
      res.json({ success: true, message: 'Account created successfully' });
    });
  }
}

module.exports = api;


Тут мы удалили методы setup и index. Метод setup нам больше не нужен, так как у нас уже есть средства для создания учётных записей. Метод index не требуется из-за того, что мы не собираемся выводить список всех зарегистрированных пользователей. Кроме того, мы избавились от console.log в методе signup, и от пустого массив клиентов в методе создания нового пользователя.

Теперь поработаем над файлом user.js, который хранится в папке services/BudgetManagerAPI/app/routes:

const models = require('@BudgetManager/app/setup');

module.exports = (app) => {
  const api = app.BudgetManagerAPI.app.api.user;

  app.route('/api/v1/signup')
     .post(api.signup(models.User));
}


Тут мы убрали маршруты, которые были нужны для старых методов.

Улучшение моделей


Перейдём к папке models, которая находится по адресу BudgetManagerAPI/app/ и внесём некоторые улучшения в модели. Откроем файл user.js. Тут мы собираемся модифицировать схему данных пользователя:

const Schema = mongoose.Schema({
  username: {
    type: String,
    unique: true,
    required: true
  },

  password: {
    type: String,
    required: true
  }
});


Кроме того, создадим ещё несколько моделей. Начнём с модели, которая будет находиться в файле client.js:

const mongoose = require('mongoose');

const Schema = mongoose.Schema({
  name: {
    type: String,
    required: true
  },

  email: {
    type: String,
    required: true
  },

  phone: {
    type: String,
    required: true
  },

  user_id: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User'
  }
});

mongoose.model('Client', Schema);


Теперь поработаем над моделью, которая будет находиться в файле budget.js:

const mongoose = require('mongoose');

const Schema = mongoose.Schema({
  client: {
    type: String,
    required: true
  },

  state: {
    type: String,
    required: true
  },

  title: {
    type: String,
    required: true
  },

  total_price: {
    type: Number,
    required: true
  },

  client_id: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'Client'
  },

  items: [{}]
});

mongoose.model('Budget', Schema);


Теперь нам не нужно использовать изменяемые массивы, увеличивающиеся по мере работы с ними. Вместо этого мы применяем ссылки для указания того, какие именно пользователи и клиенты нам нужны, используя ref и ObjectID.

Откроем файл index.js из папки setup и приведём его к такому виду:

const mongoose = require('mongoose'),
      UserModel = require('@BudgetManagerModels/user'),
      BudgetModel = require('@BudgetManagerModels/budget'),
      ClientModel = require('@BudgetManagerModels/client');

const models = {
  User: mongoose.model('User'),
  Budget: mongoose.model('Budget'),
  Client: mongoose.model('Client')
}

module.exports = models;


Расширение API


Теперь надо добавить в API методы, предназначенные для новых моделей, поэтому перейдём в папку api и создадим там новый файл client.js:

const mongoose = require('mongoose');

const api = {};

api.store = (User, Client, Token) => (req, res) => {
  if (Token) {
    const client = new Client({
      user_id: req.body.user_id,
      name: req.body.name,
      email: req.body.email,
      phone: req.body.phone,
    });

    client.save(error => {
      if (error) return res.status(400).json(error);
      res.status(200).json({ success: true, message: "Client registration successfull" });
    })
  } else return res.status(403).send({ success: false, message: 'Unauthorized' });
}

api.getAll = (User, Client, Token) => (req, res) => {
  if (Token) {
    Client.find({ user_id: req.query.user_id }, (error, client) => {
      if (error) return res.status(400).json(error);
      res.status(200).json(client);
      return true;
    })
  } else return res.status(403).send({ success: false, message: 'Unauthorized' });
}

module.exports = api;


Тут имеется метод для создания новых клиентов и для получения их полного списка. Эти методы защищены благодаря использования JWT-аутентификации.

Теперь создадим ещё один файл, назовём его budget.js:

const mongoose = require('mongoose');

const api = {};

api.store = (User, Budget, Client, Token) => (req, res) => {
  if (Token) {

    Client.findOne({ _id: req.body.client_id }, (error, client) => {
      if (error) res.status(400).json(error);

      if (client) {
        const budget = new Budget({
          client_id: req.body.client_id,
          user_id: req.body.user_id,
          client: client.name,
          state: req.body.state,
          title: req.body.title,
          total_price: req.body.total_price,
          items: req.body.items
        });

        budget.save(error => {
          if (error) res.status(400).json(error)
          res.status(200).json({ success: true, message: "Budget registered successfully" })
        })
      } else {
        res.status(400).json({ success: false, message: "Invalid client" })
      }
    })

  } else return res.status(403).send({ success: false, message: 'Unauthorized' });
}

api.getAll = (User, Budget, Token) => (req, res) => {
  if (Token) {
    Budget.find({ user_id: req.query.user_id }, (error, budget) => {
      if (error) return res.status(400).json(error);
      res.status(200).json(budget);
      return true;
    })
  } else return res.status(403).send({ success: false, message: 'Unauthorized' });
}

api.getAllFromClient = (User, Budget, Token) => (req, res) => {
  if (Token) {
    Budget.find({ client_id: req.query.client_id }, (error, budget) => {
      if (error) return res.status(400).json(error);
      res.status(200).json(budget);
      return true;
    })
  } else return res.status(403).send({ success: false, message: 'Unauthorized' });
}

module.exports = api;


Его методы, как и в предыдущем случае, защищены JWT-аутентификацией. Один из этих трёх методов используется для создания новых документов, второй — для получения списка всех документов, связанных с учётной записью пользователя, и ещё один — для получения всех документов по конкретному клиенту.

Создание и защита маршрутов для документов и клиентов


Перейдём в папку routes и создадим там файл budget.js:

module.exports = (app) => {
  const api = app.BudgetManagerAPI.app.api.budget;

  app.route('/api/v1/budget')
     .post(passport.authenticate('jwt', config.session), api.store(models.User, models.Budget, models.Client, app.get('budgetsecret')))
     .get(passport.authenticate('jwt', config.session), api.getAll(models.User, models.Budget, app.get('budgetsecret')))
     .get(passport.authenticate('jwt', config.session), api.getAllFromClient(models.User, models.Budget, app.get('budgetsecret')))
}


Затем создадим файл client.js:

const passport = require('passport'),
      config = require('@config'),
      models = require('@BudgetManager/app/setup');

module.exports = (app) => {
  const api = app.BudgetManagerAPI.app.api.client;

  app.route('/api/v1/client')
     .post(passport.authenticate('jwt', config.session), api.store(models.User, models.Client, app.get('budgetsecret')))
     .get(passport.authenticate('jwt', config.session), api.getAll(models.User, models.Client, app.get('budgetsecret')));
}


Оба эти файла похожи друг на друга. В них мы сначала вызываем метод passport.authenticate, а затем — методы API с передачей им моделей и секретного ключа.

Результаты


Теперь, если мы воспользуемся Postman для регистрации клиентов и документов, связанных с ними, вот что получится:

a8cc92b105e6394628a4bf9e4bd4548b.png

Итоги и домашнее задание


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

Сейчас же, пока следующая статья из этой серии ещё не вышла, предлагаем всем желающим, в качестве упражнения, сделать форк репозитория автора этого материала и попытаться самостоятельно реализовать средства для регистрации клиентов и документов.

Уважаемые читатели! Если вы решили выполнить домашнюю работу — просим рассказать о том, что получилось.

© Habrahabr.ru