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


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


Совершенствование компонентов


Начнём с изменения имени папки Budget на List. Кроме того, переименуем три компонента, которые находятся в этой папке. А именно, BudgetList теперь будет называться List, BudgetListHeader получит название ListHeader, а BudgetListBody — ListBody. В итоге папка и файлы компонентов должны выглядеть так, как показано на рисунке ниже.


Откроем файл компонента List и приведём его к такому виду:

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

<script>
  export default {}
</script>

Тут мы изменили имя класса таким образом, чтобы оно соответствовало имени компонента. Кроме того, мы поменяли имена слотов.

Откроем файл компонента ListHeader и внесём в него следующие изменения:

<template>
  <header class="l-list-header">
    <div class="md-list-header white--text"
         v-if="headers != null"
         v-for="header in headers">
        {{ header }}
    </div>
  </header>
</template>

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

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

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

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

    .md-list-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>

Здесь, опять же, мы поменяли имена классов, а так же отредактировали шаблон, настроив его на вывод данных из свойств (props). Так мы сможем повторно использовать этот компонент на других страницах.

Теперь пришёл черёд компонента ListBody:

<template>
  <section class="l-list-body">
    <div class="md-list-item"
         v-if="data != null"
         v-for="item in data">

         <div class="md-info white--text" v-for="info in item" v-if="info != item._id">
           {{ info }}
         </div>

        <div class="l-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: ['data']
  }
</script>

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

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

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

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

      .md-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-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>

Этот компонент мы тоже подготовили к повторному использованию, задействовав вывод данных из свойств.

Теперь откроем файл компонента Home и отредактируем его:

<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>

      <list>
        <list-header slot="list-header" :headers="budgetHeaders"></list-header>
        <list-body slot="list-body" :data="budgets"></list-body>
      </list>
    </div>

    <v-snackbar :timeout="timeout"
                bottom="bottom"
                color="red lighten-1"
                v-model="snackbar">
      {{ message }}
    </v-snackbar>
  </main>
</template>

<script>
  import Axios from 'axios'
  import Authentication from '@/components/pages/Authentication'
  import ListHeader from './../List/ListHeader'
  import ListBody from './../List/ListBody'

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

  export default {
    components: {
      'list-header': ListHeader,
      'list-body': ListBody
    },
    data () {
      return {
        budgets: [],
        clients: [],
        budgetHeaders: ['Client', 'Title', 'Status', 'Actions'],
        clientHeaders: ['Client', 'Email', 'Phone', 'Actions'],
        snackbar: false,
        timeout: 6000,
        message: ''
      }
    },
    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 = this.dataParser(data, '_id', 'client', 'title', 'state')
        }).catch(error => {
          this.snackbar = true
          this.message = error.message
        })
      },

      dataParser (targetedArray, ...options) {
        let parsedData = []
        targetedArray.forEach(item => {
          let parsedItem = {}
          options.forEach(option => (parsedItem[option] = item[option]))
          parsedData.push(parsedItem)
        })
        return parsedData
      }
    }
  }
</script>

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

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

Здесь мы поменяли команды импорта и теги, привели их в соответствие переименованным компонентам, добавили snackbar, что даёт возможность показывать сообщения об ошибках в том случае, если нам не удастся получить данные. Кроме того, мы добавили сюда новый массив для данных компонента, budgetHeaders, а также данные, необходимые для snackbar.

Мы будем использовать budgetHearders для показа заголовков списка. Кроме того, мы внесли некоторые изменения в метод getAllBudgets:

getAllBudgets () {
  Axios.get(`${BudgetManagerAPI}/api/v1/budget`, {
    headers: { 'Authorization': Authentication.getAuthenticationHeader(this) },
    params: { user_id: this.$cookie.get('user_id') }
  }).then(({data}) => {
    this.budgets = this.dataParser(data, '_id', 'client', 'title', 'state')
  }).catch(error => {
    this.snackbar = true
    this.message = error.message
  })
},

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

dataParser (targetedArray, ...options) {
  let parsedData = []
  targetedArray.forEach(item => {
    let parsedItem = {}
    options.forEach(option => (parsedItem[option] = item[option]))
    parsedData.push(parsedItem)
  })
  return parsedData
}

Этот метод, в качестве первого аргумента, принимает массив и произвольное число аргументов, которые сформируют массив options с использованием оператора расширения.

Метод будет брать каждый элемент из массива, в данном случае это — документы, и создавать новый объект parsedItem.

Этот объект будет содержать данные массива options, после завершения его подготовки он будет помещён в массив parsedData, который мы возвращаем из этого метода.

И, наконец, мы перехватываем ошибки (если таковые возникнут), активируя snackbar.

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

import Vue from 'vue'
import Router from 'vue-router'
import * as Auth from '@/components/pages/Authentication'

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

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

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

Vue.use(Router)

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

router.beforeEach((to, from, next) => {
  if (to.path !== '/login') {
    if (Auth.default.user.authenticated) {
      next()
    } else {
      router.push('/login')
    }
  } else {
    next()
  }
})

export default router

Тут мы поправили команды импорта, имена компонентов, теги и пути, а также сделали некоторые улучшения в router.beforeEach, так как мы собираемся защищать любой маршрут, отличающийся от login, мы убираем meta из маршрута страницы Home.

Вывод информации о клиентах


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

return {
  budgets: [],
  clients: [],
  budgetHeaders: ['Client', 'Title', 'Status', 'Actions'],
  clientHeaders: ['Client', 'Email', 'Phone', 'Actions'],
  budgetsVisible: true,
  snackbar: false,
  timeout: 6000,
  message: ''
}

Теперь добавим новый метод:

getAllClients () {
  Axios.get(`${BudgetManagerAPI}/api/v1/client`, {
    headers: { 'Authorization': Authentication.getAuthenticationHeader(this) },
    params: { user_id: this.$cookie.get('user_id') }
  }).then(({data}) => {
    this.clients = this.dataParser(data, '_id', 'client', 'email', 'phone')
  }).catch(error => {
    this.snackbar = true
    this.message = error.message
  })
},

Вызовем этот метод при монтировании компонента:

mounted () {
  this.getAllBudgets()
  this.getAllClients()
},

Как теперь вывести сведения о клиентах? Очень просто. Достаточно внести ещё некоторые изменения в компонент Home:

<template>
  <main class="l-home-page">
    <app-header :budgetsVisible="budgetsVisible" @toggleVisibleData="budgetsVisible = !budgetsVisible"></app-header>

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

      <list>
        <list-header slot="list-header" :headers="budgetsVisible ? budgetHeaders : clientHeaders"></list-header>
        <list-body slot="list-body"
                   :budgetsVisible="budgetsVisible"
                   :data="budgetsVisible ? budgets : clients">
        </list-body>
      </list>
    </div>

    <v-snackbar :timeout="timeout"
                bottom="bottom"
                color="red lighten-1"
                v-model="snackbar">
      {{ message }}
    </v-snackbar>
  </main>
</template>

<script>
  import Axios from 'axios'
  import Authentication from '@/components/pages/Authentication'
  import ListHeader from './../List/ListHeader'
  import ListBody from './../List/ListBody'

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

  export default {
    components: {
      'list-header': ListHeader,
      'list-body': ListBody
    },
    data () {
      return {
        budgets: [],
        clients: [],
        budgetHeaders: ['Client', 'Title', 'Status', 'Actions'],
        clientHeaders: ['Client', 'Email', 'Phone', 'Actions'],
        budgetsVisible: false,
        snackbar: false,
        timeout: 6000,
        message: ''
      }
    },
    mounted () {
      this.getAllBudgets()
      this.getAllClients()
    },
    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 = this.dataParser(data, '_id', 'client', 'title', 'state')
        }).catch(error => {
          this.snackbar = true
          this.message = error.message
        })
      },

      getAllClients () {
        Axios.get(`${BudgetManagerAPI}/api/v1/client`, {
          headers: { 'Authorization': Authentication.getAuthenticationHeader(this) },
          params: { user_id: this.$cookie.get('user_id') }
        }).then(({data}) => {
          this.clients = this.dataParser(data, 'name', 'client', 'email', 'phone')
        }).catch(error => {
          this.snackbar = true
          this.message = error.message
        })
      },

      dataParser (targetedArray, ...options) {
        let parsedData = []
        targetedArray.forEach(item => {
          let parsedItem = {}
          options.forEach(option => (parsedItem[option] = item[option]))
          parsedData.push(parsedItem)
        })
        return parsedData
      }
    }
  }
</script>

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

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

Теперь мы передаём переменную budgetVisible в Header и, кроме того, используем эту переменную в тернарном операторе сравнения для вывода нужных данных. В Header так же попадает переменная toggleVisibleData, где мы инвертируем значение budgetsVisible. Причина, по которой мы передаём в Header свойства, заключается в том, что благодаря такому подходу мы можем сделать ещё некоторые улучшения, о которых поговорим ниже. Кроме того, в слотах list-header и list-body мы используем тернарные операторы сравнения.

Итак, теперь внесём улучшения в Header:

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

      <v-flex xs12 offset-md1 md1>
        <v-btn block
               :color="budgetsVisible ? 'light-blue lighten-1' : 'green lighten-1'"
               @click.native="$emit('toggleVisibleData')">
               {{ budgetsVisible ? "Clients" : "Budgets" }}
        </v-btn>
      </v-flex>

      <v-flex xs12 offset-md1 md2>
        <v-select label="Status"
                  :color="budgetsVisible ? 'light-blue lighten-1' : 'green 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 {
    props: ['budgetsVisible'],
    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;

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

    .l-clients-header {
      label, input, .icon, .input-group__selections__comma {
        color: #66bb6a!important;
      }
    }

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

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

Теперь цвет заголовка будет зависеть от состояния переменной budgetsVisible. Если документы видимы, заголовок будет иметь светло-синий цвет, если нет — зелёный.

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

Кроме того, мы внесли некоторые изменения в scss.

И, наконец, займёмся компонентом ListBody:

<template>
  <section class="l-list-body">
    <div class="md-list-item"
         v-if="data != null"
         v-for="item in data">

         <div :class="budgetsVisible ? 'md-budget-info white--text' : 'md-client-info white--text'"
              v-for="info in item"
              v-if="info != item._id">
           {{ info }}
         </div>

        <div :class="budgetsVisible ? 'l-budget-actions white--text' : 'l-client-actions white--text'">
          <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: ['data', 'budgetsVisible']
  }
</script>

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

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

    .md-list-item {
      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;
        }
      }

      .md-client-info {
        @extend .md-budget-info;
        background-color: rgba(102, 187, 106, 0.45)!important;

        &:nth-of-type(2) {
          text-transform: none;
        }
      }

      .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;
        }
      }

      .l-client-actions {
        @extend .l-budget-actions;
        background-color: rgba(102, 187, 106, 0.45)!important;
      }
    }
  }
</style>

Изменения, внесённые сюда, похожи на те, что мы выполнили в коде компонента Header.

Промежуточные результаты


Вот как теперь выглядит список документов:



А вот — список клиентов:



Теперь, когда мы можем видеть списки зарегистрированных документов и клиентов, создадим плавающую кнопку (Floating Action Button, FAB), которая будет содержать кнопки, позволяющие работать со списком. Всё ещё находясь в коде компонента Home, добавим следующий код ниже v-snackbar:

<v-fab-transition>
  <v-speed-dial v-model="fab"
                bottom
                right
                fixed
                direction="top"
                transition="scale-transition">
      <v-btn slot="activator"
             color="red lighten-1"
             dark
             fab
             v-model="fab">
            <v-icon>add</v-icon>
            <v-icon>close</v-icon>
      </v-btn>

      <v-tooltip left>
        <v-btn color="light-blue lighten-1"
               dark
               small
               fab
               slot="activator">
              <v-icon>assignment</v-icon>
        </v-btn>
        <span>Add new Budget</span>
      </v-tooltip>

      <v-tooltip left>
        <v-btn color="green lighten-1"
               dark
               small
               fab
               slot="activator">
              <v-icon>account_circle</v-icon>
        </v-btn>
        <span>Add new Client</span>
      </v-tooltip>

  </v-speed-dial>
</v-fab-transition>

В FAB содержится три кнопки. Первая действует как активатор для FAB, вторая служит для добавления документов, третья — для добавления клиентов. Добавим теперь новое логическое значение для FAB в данные компонента Home:

data () {
  return {
    budgets: [],
    clients: [],
    budgetHeaders: ['Client', 'Title', 'Status', 'Actions'],
    clientHeaders: ['Client', 'Email', 'Phone', 'Actions'],
    budgetsVisible: true,
    snackbar: false,
    timeout: 6000,
    message: '',
    fab: false
  }
},

Здесь мы добавили логическое значение fab, которое используется для указания того, активна плавающая кнопка или нет.

Итоги


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

В следующем материале мы продолжим работу над приложением, и, вероятнее всего, её завершим.

Уважаемые читатели! Стремитесь ли вы к возможности повторного использования кода при работе над своими проектами?




К сожалению, не доступен сервер mySQL