Сегодня публикуем третью часть из серии материалов, посвящённой разработке приложения 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 для регистрации клиентов и документов, связанных с ними, вот что получится:
Итоги и домашнее задание
В этом материале мы исправили некоторые недочёты, поработали над клиентской и серверной частями приложения, начав реализацию его основной логики. В следующий раз мы продолжим развивать проект, в частности, разработаем механизмы для регистрации новых клиентов и создания связанных с ними финансовых документов.
Сейчас же, пока следующая статья из этой серии ещё не вышла, предлагаем всем желающим, в качестве упражнения, сделать форк репозитория автора этого материала и попытаться самостоятельно реализовать средства для регистрации клиентов и документов.
Уважаемые читатели! Если вы решили выполнить домашнюю работу — просим рассказать о том, что получилось.
Автор: ru_vds