Создание простого счетчика расходов с помощью BootstrapVue 2
Выпущен BootstrapVue 2, совместимый с Vue.js 2.6 или новее. В этом выпуске много негласных улучшений и критических изменений. В этом выпуске также представлены улучшения производительности.
По большей части создание приложения с помощью BootstrapVue 2 очень близко к тому, как вы строите с помощью предыдущих версий BootstrapVue. Вы можете просмотреть полный список изменений в Журнале изменений Bootstrap.
В этой статье мы создадим простой трекер расходов с формой для добавления описания, суммы и даты расходов. Мы также построим таблицу для отображения данных и добавим кнопки, позволяющие пользователю удалять расходы. Кроме того, будет страница с графиком расходов, отсортированных по дате.
Для простоты серверная часть будет построена с использованием Koa. Мы будем использовать последнюю версию Vue.js с последней версией BootstrapVue для создания пользовательского интерфейса. Vee-Validate будет использоваться для проверки формы, а Vue-Chartjs будет использоваться для линейного графика.
Back End
Для начала создадим простую серверную часть для хранения расходов. Создайте папку проекта, а затем создайте папку backend
для хранения внутреннего кода. Теперь мы можем построить серверную часть, и для этого мы запускаем npm init
и отвечаем на вопросы, вводя значения по умолчанию. Затем мы устанавливаем собственные пакеты. У Koa ничего нет, поэтому нам нужно установить парсер тела запроса, маршрутизатор, надстройку CORS, чтобы разрешить междоменные запросы с интерфейсом пользователя, и добавить библиотеки для базы данных.
Чтобы установить все эти пакеты, запустите npm i @babel/cli @babel/core @babel/node @babel/preset-env @koa/cors koa-bodyparser koa-router sequelize sqlite3
. Нам нужны пакеты Babel для работы с последними функциями JavaScript. Sequelize и SQLite - это ORM и база данных, которые мы будем использовать соответственно. Пакеты Koa предназначены для включения CORS, анализа тела запроса JSON и включения маршрутизации соответственно.
Следующий запуск:
npx sequelize-cli init
Это создаст шаблонный код базы данных.
Затем запускаем:
npx sequelize-cli --name Expense --attributes description:string,amount:float,date:date
Это создаст Expense
table с полями и типами данных, перечисленными в опции attributes
.
После этого запустите npx sequelize-cli db:migrate
, чтобы создать базу данных.
Затем создайте app.js
в корне папки backend
и добавьте:
const Koa = require("koa"); const cors = require("@koa/cors"); const Router = require("koa-router"); const models = require("./models"); const bodyParser = require("koa-bodyparser"); const app = new Koa(); app.use(bodyParser()); app.use(cors()); const router = new Router(); router.get("/expenses", async (ctx, next) => { const Expenses = await models.Expense.findAll(); ctx.body = Expenses; }); router.post("/expenses", async (ctx, next) => { const Expense = await models.Expense.create(ctx.request.body); ctx.body = Expense; }); router.delete("/expenses/:id", async (ctx, next) => { const id = ctx.params.id; await models.Expense.destroy({ where: { id } }); ctx.body = {}; }); app.use(router.routes()).use(router.allowedMethods()); app.listen(3000);
Это файл со всей логикой для нашего приложения. Мы используем модель Sequelize, созданную путем импорта модуля models
, созданного при запуске sequelize-cli init
.
Затем мы включаем CORS, добавляя app.use(cors());
. Разбор тела запроса JSON включается добавлением app.use(bodyParser());
. Добавляем роутер, добавляя const router = new Router();
.
В GET expense
route мы получаем все расходы. POST предназначен для добавления контакта. Маршрут PUT используется для обновления существующих расходов путем поиска их по идентификатору. А маршрут DELETE предназначен для удаления расходов по идентификатору.
Теперь задняя часть готова. Это так просто.
Внешний интерфейс
Чтобы начать создание внешнего интерфейса, мы добавляем папку frontend
в корневую папку проекта, а затем заходим в папку frontend
и запускаем:
npx @vue/cli create .
Когда мы запустим мастер, мы вручную выберем параметры и включим Vuex, Vue Router и SCSS. Мы также будем использовать NPM для управления пакетами.
Далее мы устанавливаем несколько пакетов. Мы будем использовать Axios для создания запросов, Moment для управления датами, BootstrapVue для стилизации, Vee-Validate для проверки формы и Vue-Chartjs для отображения нашей диаграммы.
Для установки всего запускаем:
npm i axios bootstrap-vue chartjs vue-chartjs vee-validate moment
Установив все пакеты, мы можем приступить к написанию кода.
В папке src
создайте папку charts
и создайте внутри нее файл с именем ExpenseChart.vue
. В файле добавить:
<script> import { Line } from "vue-chartjs"; export default { extends: Line, props: ["chartdata", "options"], mounted() { this.renderChart(this.chartdata, this.options); }, watch: { chartdata() { this.renderChart(this.chartdata, this.options); }, options() { this.renderChart(this.chartdata, this.options); } } }; </script> <style> </style>
Мы указываем, что этот компонент принимает реквизиты chartdata
и options
. Мы вызываем this.renderChart
в блоках mounted
и watch
, чтобы диаграмма обновлялась при изменении свойств или при первой загрузке этого компонента.
Далее мы создаем фильтр для форматирования дат. Создайте папку filters
в папке src
и внутри нее создайте date.js
. В файл добавляем:
import * as moment from "moment"; export const dateFilter = value => { return moment(value).format("YYYY-MM-DD"); };
Это займет дату, а затем вернет ее в формате ГГГГ-ММ-ДД.
Затем мы создаем миксин для кода HTTP-запроса. Создайте папку mixins
в папке src
и в ней создайте requestsMixin.js
и добавьте:
const APIURL = "http://localhost:3000"; const axios = require("axios"); export const requestsMixin = { methods: { getExpenses() { return axios.get(`${APIURL}/expenses`); }, addExpense(data) { return axios.post(`${APIURL}/expenses`, data); }, deleteExpense(id) { return axios.delete(`${APIURL}/expenses/${id}`); } } };
Это позволяет нам вызывать эти функции в любом компоненте, когда миксин включен в компонент.
Далее в папке views
мы создаем наши компоненты. Создайте файл Graph.vue
в папке views
и добавьте:
<template> <div class="about"> <h1 class="text-center">Expense Chart</h1> <expense-chart :chartdata="chartData" :options="options"></expense-chart> </div> </template> <script> import { requestsMixin } from "../mixins/requestsMixin"; import * as moment from "moment"; export default { name: "home", mixins: [requestsMixin], data() { return { chartData: {}, options: { responsive: true, maintainAspectRatio: false } }; }, beforeMount() { this.getAllExpenses(); }, methods: { async getAllExpenses() { const response = await this.getExpenses(); const sortedData = response.data.sort( (a, b) => +moment(a.date).toDate() - +moment(b.date).toDate() ); const dates = Array.from( new Set(sortedData.map(d => moment(d.date).format("YYYY-MM-DD"))) ); const expensesByDate = {}; dates.forEach(d => { expensesByDate[d] = 0; }); dates.forEach(d => { const data = sortedData.filter( sd => moment(sd.date).format("YYYY-MM-DD") == d ); expensesByDate[d] += +data .map(a => +a.amount) .reduce((a, b) => { return a + b; }); }); this.chartData = { labels: dates, datasets: [ { label: "Expenses", backgroundColor: "#f87979", data: Object.keys(expensesByDate).map(d => expensesByDate[d]) } ] }; } } }; </script>
Здесь мы отображаем ExpenseChart
, который мы создали ранее. Данные заполняются путем получения их из серверной части. Функция this.getExpenses
взята из requestMixin
, который мы включили в этот файл. Чтобы сгенерировать chartData
, мы сортируем данные по дате, а затем устанавливаем даты как labels
. Затем в datasets
у нас есть данные для строки, которая представляет собой сумму расходов. Мы складываем все расходы за каждый день и конвертируем в массив с:
const dates = Array.from( new Set(sortedData.map(d => moment(d.date).format("YYYY-MM-DD"))) ); const expensesByDate = {}; dates.forEach(d => { expensesByDate[d] = 0; }); dates.forEach(d => { const data = sortedData.filter( sd => moment(sd.date).format("YYYY-MM-DD") == d ); expensesByDate[d] += +data .map(a => +a.amount) .reduce((a, b) => { return a + b; }); });
В приведенном выше коде мы конвертируем расходы в словарь с датой в качестве ключа и общими расходами в качестве значения.
Мы делаем диаграмму адаптивной, передав { responsive: true, maintainAspectRatio: false }
в опору options
.
Затем мы заменяем существующий код в HomePage.vue
на:
<template> <div class="page"> <ValidationObserver ref="observer" v-slot="{ invalid }"> <b-form @submit.prevent="onSubmit" novalidate> <b-form-group label="Description"> <ValidationProvider name="description" rules="required" v-slot="{ errors }"> <b-form-input :state="errors.length == 0" v-model="form.description" type="text" required placeholder="Description" name="description" ></b-form-input> <b-form-invalid-feedback :state="errors.length == 0">Description is required</b-form-invalid-feedback> </ValidationProvider> </b-form-group> <b-form-group label="Amount"> <ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }"> <b-form-input :state="errors.length == 0" v-model="form.amount" type="text" required placeholder="Amount" ></b-form-input> <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback> </ValidationProvider> </b-form-group> <b-form-group label="Date"> <ValidationProvider name="amount" rules="date" v-slot="{ errors }"> <b-form-input :state="errors.length == 0" v-model="form.date" type="text" required placeholder="Date (YYYY/MM/DD)" name="date" ></b-form-input> <b-form-invalid-feedback :state="errors.length == 0">{{errors[0]}}</b-form-invalid-feedback> </ValidationProvider> </b-form-group> <b-button type="submit">Add</b-button> </b-form> </ValidationObserver> <b-table-simple responsive> <b-thead> <b-tr> <b-th sticky-column>Description</b-th> <b-th>Amount</b-th> <b-th>Date</b-th> <b-th>Delete</b-th> </b-tr> </b-thead> <b-tbody> <b-tr v-for="e in expenses" :key="e.id"> <b-th sticky-column>{{e.description}}</b-th> <b-td>${{e.amount}}</b-td> <b-td>{{e.date | formatDate}}</b-td> <b-td> <b-button @click="deleteSingleExpense(e.id)">Delete</b-button> </b-td> </b-tr> </b-tbody> </b-table-simple> </div> </template> <script> import "bootstrap/dist/css/bootstrap.css"; import "bootstrap-vue/dist/bootstrap-vue.css"; import { requestsMixin } from "../mixins/requestsMixin"; export default { name: "home", mixins: [requestsMixin], data() { return { form: {} }; }, beforeMount() { this.getAllExpenses(); }, computed: { expenses() { return this.$store.state.expenses; } }, methods: { async onSubmit() { const isValid = await this.$refs.observer.validate(); if (!isValid) { return; } await this.addExpense(this.form); await this.getAllExpenses(); }, async getAllExpenses() { const response = await this.getExpenses(); this.$store.commit("setExpenses", response.data); }, async deleteSingleExpense(id) { const response = await this.deleteExpense(id); await this.getAllExpenses(); } } }; </script>
Мы используем форму и таблицу из BootstrapVue для создания формы и таблицы. Для проверки формы мы оборачиваем ValidationProvider
вокруг каждого b-form-input
, чтобы получить проверку формы. Правила проверки формы зарегистрированы в main.js
, поэтому мы можем использовать их здесь.
Мы добавляем :state=”errors.length == 0"
в каждый b-form-input
, чтобы получить правильное сообщение проверки, отображаемое и оформленное должным образом для каждого ввода. Объект errors
имеет сообщения об ошибках проверки формы для каждого ввода. Нам также необходимо указать опору name
в ValidationProvider
и b-form-input
, чтобы правила проверки формы применялись к входным данным внутри ValidationProvider
. Мы помещаем форму внутрь компонента ValidationObserver
, чтобы мы могли проверить всю форму. С Vee-Validate мы получаем функцию this.$refs.observer.validate()
, когда используем ValidationObserver
, как мы это делали в приведенном выше коде. Он возвращает обещание, которое принимает значение true, если форма действительна, и false в противном случае. Поэтому, если он принимает значение false, мы не запускаем остальную часть кода функции.
this.$store
предоставляется Vuex. Мы вызываем commit
для сохранения значений в хранилище Vuex. Последние значения мы получаем из магазина в блоке computed
.
Затем в App.vue
замените существующий код на:
<template> <div id="app"> <b-navbar toggleable="lg" type="dark" variant="info"> <b-navbar-brand href="#">Expense Tracker</b-navbar-brand> <b-navbar-toggle target="nav-collapse"></b-navbar-toggle> <b-collapse id="nav-collapse" is-nav> <b-navbar-nav> <b-nav-item to="/" :active="path == '/'">Home</b-nav-item> <b-nav-item to="/graph" :active="path == '/graph'">Graph</b-nav-item> </b-navbar-nav> </b-collapse> </b-navbar> <router-view /> </div> </template> <script> export default { beforeMount() { window.Chart.defaults.global.defaultFontFamily = ` -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif`; }, data() { return { path: this.$route && this.$route.path }; }, watch: { $route(route) { this.path = route.path; } } }; </script> <style lang="scss"> .page { padding: 20px; } </style>
В этом файле мы добавляем BootstrapVue b-navbar
для отображения верхней панели. Мы следим за изменениями URL-адресов, чтобы установить активную правильную ссылку. В блоке data
мы устанавливаем начальный маршрут, чтобы при первой загрузке приложения отображалась правильная ссылка.
Затем в main.js
мы заменяем существующий код на:
import Vue from "vue"; import App from "./App.vue"; import router from "./router"; import store from "./store"; import BootstrapVue from "bootstrap-vue"; import { ValidationProvider, extend, ValidationObserver } from "vee-validate"; import { required, min_value } from "vee-validate/dist/rules"; import { dateFilter } from "./filters/date"; import ExpenseChart from "./charts/ExpenseChart"; extend("required", required); extend("min_value", min_value); extend("date", { validate: value => /^(19|20)\d\d[/]([1-9]|0[1-9]|1[012])[/]([1-9]|0[1-9]|[12][0-9]|3[01])$/.test( value ), message: "Date must be in YYYY/MM/DD format" }); Vue.component("expense-chart", ExpenseChart); Vue.filter("formatDate", dateFilter); Vue.use(BootstrapVue); Vue.component("ValidationProvider", ValidationProvider); Vue.component("ValidationObserver", ValidationObserver); Vue.config.productionTip = false; new Vue({ router, store, render: h => h(App) }).$mount("#app");
Сюда добавляются правила проверки, которые мы использовали в Home.vue
. Обратите внимание, что у нас могут быть собственные правила проверки, такие как правило date
. С VeeValidate 3 легко определять собственные правила. Мы также регистрируем компоненты ValidationProvider
и ExpenseChart
, чтобы использовать их в нашем приложении.
Мы регистрируем здесь компонент ValidationObserver
, чтобы мы могли проверить всю форму в HomePage.vue
.
В router.js
мы заменяем существующий код на:
import Vue from 'vue' import Router from 'vue-router' import Home from './views/Home.vue' import Graph from './views/Graph.vue' Vue.use(Router) export default new Router({ mode: 'history', base: process.env.BASE_URL, routes: [ { path: '/', name: 'home', component: Home }, { path: '/graph', name: 'graph', component: Graph } ] })
Это будет включать страницу Graph
, которую мы создали.
В store.js
мы заменяем код на:
import Vue from "vue"; import Vuex from "vuex"; Vue.use(Vuex); export default new Vuex.Store({ state: { expenses: [] }, mutations: { setExpenses(state, payload) { state.expenses = payload; } }, actions: {} });
Теперь мы можем хранить данные о расходах в магазине, когда они будут получены.
Наконец, в index.html
мы меняем существующий код на:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" /> <link rel="icon" href="<%= BASE_URL %>favicon.ico" /> <title>Expense Tracker</title> </head> <body> <noscript> <strong >We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong > </noscript> <div id="app"></div> <!-- built files will be auto injected --> </body> </html>
Это изменит заголовок. Мы уже импортировали стили Bootstrap в App.vue
, поэтому нам не нужно включать их здесь.
После написания всего этого кода мы можем запустить наше приложение. Прежде чем что-либо запускать, установите nodemon
, запустив npm i -g nodemon
, чтобы нам не пришлось перезапускать серверную часть самостоятельно при изменении файлов.
Затем запустите серверную часть, запустив npm start
в папке backend
и npm run serve
в папке frontend
.
В итоге имеем следующее: