Создание простого счетчика расходов с помощью 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

Это создаст Expensetable с полями и типами данных, перечисленными в опции 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 expenseroute мы получаем все расходы. 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.

В итоге имеем следующее: