javascript

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

  • суббота, 11 ноября 2017 г. в 03:14:15
https://habrahabr.ru/company/ruvds/blog/341874/
  • Разработка веб-сайтов
  • Node.JS
  • MongoDB
  • JavaScript
  • Блог компании RUVDS.com


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



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


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

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

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

<style lang="scss">
  @import "./assets/styles";
   body {
    background: url('./assets/images/background.jpg') no-repeat  center center fixed;
    background-size: cover;
    &:after {
      content: '';
      position: fixed;
      width: 100%;
      height: 100%;
      top: 0;
      left: 0;
      background-color: $background-tint;
      opacity: .3;
      z-index: -1;
    }
    .application {
      background: none;
    }
  }
</style>

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

.application {
  background: none;
}

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

<template>
  <v-app>
    <v-container>
      <router-view/>
    </v-container>
  </v-app>
</template>

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

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

<style lang="scss">
  @import "./../../../assets/styles";
  .l-auth {
    background-color: $background-color;
    padding: 15px;
    margin: 45px auto;
    min-width: 272px;
    max-width: 320px;
    animation: bounceIn 1s forwards ease;
    label, input, .icon {
      color: #29b6f6!important;
    }
    .input-group__details {
      &:before {
        background-color: $border-color-input !important;
      }
    }
  }
  .l-signup {
    @extend .l-auth;
    animation: slideInFromLeft 1s forwards ease;
  }
</style>

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



Теперь переходим к файлу 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:

<template>
  <header class="l-header-container">
    <v-layout row wrap>
      <v-flex xs12 md5>
        <v-text-field v-model="search"
                      label="Search"
                      append-icon="search"
                      color="light-blue lighten-1">
        </v-text-field>
      </v-flex>

      <v-flex xs12 offset-md1 md1>
        <v-btn block color="light-blue lighten-1">Clients</v-btn>
      </v-flex>

      <v-flex xs12 offset-md1 md2>
        <v-select label="Status"
                  color="light-blue lighten-1"
                  v-model="status"
                  :items="statusItems"
                  single-line>
        </v-select>
      </v-flex>

      <v-flex xs12 offset-md1 md1>
        <v-btn block color="red lighten-1 white--text" @click.native="submitSignout()">Sign out</v-btn>
      </v-flex>
    </v-layout>
  </header>
</template>

<script>
  import Authentication from '@/components/pages/Authentication'
  export default {
    data () {
      return {
        search: '',
        status: '',
        statusItems: [
          'All', 'Approved', 'Denied', 'Waiting', 'Writing', 'Editing'
        ]
      }
    },
    methods: {
      submitSignout () {
        Authentication.signout(this, '/login')
      }
    }
  }
</script>

<style lang="scss">
  @import "./../assets/styles";

  .l-header-container {
    background-color: $background-color;
    margin: 0 auto;
    padding: 0 15px;
    min-width: 272px;

    label, input, .icon, .input-group__selections__comma {
      color: #29b6f6!important;
    }

    .input-group__details {
      &:before {
        background-color: $border-color-input !important;
      }
    }

    .btn {
      margin-top: 15px;
    }
  }
</style>

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

<template>
  <main class="l-home-page">
    <app-header></app-header>

    <div class="l-home">
      <h4 class="white--text text-xs-center my-0">
        Focus Budget Manager
      </h4>

      <budget-list>
        <budget-list-header slot="budget-list-header"></budget-list-header>
        <budget-list-body slot="budget-list-body" :budgets="budgets"></budget-list-body>
      </budget-list>
    </div>
  </main>
</template>

<script>
  import Axios from 'axios'
  import Authentication from '@/components/pages/Authentication'
  import BudgetListHeader from './../Budget/BudgetListHeader'
  import BudgetListBody from './../Budget/BudgetListBody'

  const BudgetManagerAPI = `http://${window.location.hostname}:3001`

  export default {
    components: {
      'budget-list-header': BudgetListHeader,
      'budget-list-body': BudgetListBody
    },
    data () {
      return {
        budgets: []
      }
    },
    mounted () {
      this.getAllBudgets()
    },
    methods: {
      getAllBudgets () {
        Axios.get(`${BudgetManagerAPI}/api/v1/budget`, {
          headers: { 'Authorization': Authentication.getAuthenticationHeader(this) },
          params: { user_id: this.$cookie.get('user_id') }
        }).then(({data}) => (this.budgets = data))
      }
    }
  }
</script>

<style lang="scss" scoped>
  @import "./../../assets/styles";

  .l-home {
    background-color: $background-color;
    margin: 25px auto;
    padding: 15px;
    min-width: 272px;
  }
</style>

Тут мы выводим заголовок, представленный тегом 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:

<template>
  <header class="l-budget-header">
    <div class="md-budget-header white--text">Client</div>
    <div class="md-budget-header white--text">Title</div>
    <div class="md-budget-header white--text">Status</div>
    <div class="md-budget-header white--text">Actions</div>
  </header>
</template>

<script>
  export default {}
</script>

<style lang="scss">
  @import "./../../assets/styles";

  .l-budget-header {
    display: none;
    width: 100%;

    @media (min-width: 601px) {
      margin: 25px 0 0;
      display: flex;
    }

    .md-budget-header {
      width: 100%;
      background-color: $background-color;
      border: 1px solid $border-color-input;
      padding: 0 15px;
      display: flex;
      height: 45px;
      align-items: center;
      justify-content: center;
      font-size: 22px;

      @media (min-width: 601px) {
        justify-content: flex-start;
      }
    }
  }
</style>

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

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

<template>
  <section class="l-budget-body">
    <div class="md-budget" v-if="budgets != null" v-for="budget in budgets">
      <div class="md-budget-info white--text">{{ budget.client }}</div>
      <div class="md-budget-info white--text">{{ budget.title }}</div>
      <div class="md-budget-info white--text">{{ budget.state }}</div>
      <div class="l-budget-actions">
        <v-btn small flat color="light-blue lighten-1">
          <v-icon small>visibility</v-icon>
        </v-btn>
        <v-btn small flat color="yellow accent-1">
          <v-icon>mode_edit</v-icon>
        </v-btn>
        <v-btn small flat color="red lighten-1">
          <v-icon>delete_forever</v-icon>
        </v-btn>
      </div>
    </div>
  </section>
</template>

<script>
  export default {
    props: ['budgets']
  }
</script>

<style lang="scss">
  @import "./../../assets/styles";

  .l-budget-body {
    display: flex;
    flex-direction: column;

    .md-budget {
      width: 100%;
      display: flex;
      flex-direction: column;
      margin: 15px 0;

      @media (min-width: 960px) {
        flex-direction: row;
        margin: 0;
      }

      .md-budget-info {
        flex-basis: 25%;
        width: 100%;
        background-color: rgba(0, 175, 255, 0.45);
        border: 1px solid $border-color-input;
        padding: 0 15px;
        display: flex;
        height: 35px;
        align-items: center;
        justify-content: center;

        &:first-of-type, &:nth-of-type(2) {
          text-transform: capitalize;
        }

        &:nth-of-type(3) {
          text-transform: uppercase;
        }

        @media (min-width: 601px) {
          justify-content: flex-start;
        }
      }

      .l-budget-actions {
        flex-basis: 25%;
        display: flex;
        background-color: rgba(0, 175, 255, 0.45);
        border: 1px solid $border-color-input;
        align-items: center;
        justify-content: center;

        .btn {
          min-width: 45px !important;
          margin: 0 5px !important;
        }
      }
    }
  }
</style>

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

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

<template>
  <section class="l-budget-list-container">
    <slot name="budget-list-header"></slot>
    <slot name="budget-list-body"></slot>
  </section>
</template>

<script>
  export default {}
</script>

Обратите внимание на теги 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 для регистрации клиентов и документов, связанных с ними, вот что получится:



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


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

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

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