Categories
JavaScript

Formatting Numbers in JavaScript with the NumberFormat Constructor

JavaScript has great internationalization features. One of them is its ability to format numbers for non-English users.

This means that we can display numbers for people using non-English locales without adding another library to do this. We can format numbers with the Intl.NumberFormat constructor. The constructor takes 2 arguments.

The first argument is the locale string or an array of locale strings. Locale strings should be BCP 47 language tags with Unicode key extensions optionally attached. The object created by the constructor has a format method which takes a number that we want to format.

Constructor and Format Method

To use the Intl.NumberFormat constructor, we can create an object with the constructor and then use the format method on the newly created object from the constructor to format the number. We can write something like the following code:

console.log(new Intl.NumberFormat('en', {
  style: 'currency',
  currency: 'GBP'
}).format(222));

The code above formats the number 222 into a price amount denominated in the British Pound. We did that by passing in the style option with the currency value, and the currency property set to GBP, which is the currency symbol for the British Pound.

The Intl.NumberFormat constructor takes 2 arguments, first is the locales argument, which takes one locale string or an array of locale strings. This is an optional argument. It takes a BCP 47 language tag with the optional Unicode extension key nu to specify the numbering system to format the number to. Possible values for nu include: "arab", "arabext", "bali", "beng", "deva", "fullwide", "gujr", "guru", "hanidec", "khmr", "knda", "laoo", "latn", "limb", "mlym", "mong", "mymr", "orya", "tamldec", "telu", "thai", "tibt".

The instance of the object created by the constructor has the format method returns a string with the formatted number. An abridged list of BCP 47 language tags include:

  • ar — Arabic
  • bg — Bulgarian
  • ca — Catalan
  • zh-Hans — Chinese, Han (Simplified variant)
  • cs — Czech
  • da — Danish
  • de — German
  • el — Modern Greek (1453 and later)
  • en — English
  • es — Spanish
  • fi — Finnish
  • fr — French
  • he — Hebrew
  • hu — Hungarian
  • is — Icelandic
  • it — Italian
  • ja — Japanese
  • ko — Korean
  • nl — Dutch
  • no — Norwegian
  • pl — Polish
  • pt — Portuguese
  • rm — Romansh
  • ro — Romanian
  • ru — Russian
  • hr — Croatian
  • sk — Slovak
  • sq — Albanian
  • sv — Swedish
  • th — Thai
  • tr — Turkish
  • ur — Urdu
  • id — Indonesian
  • uk — Ukrainian
  • be — Belarusian
  • sl — Slovenian
  • et — Estonian
  • lv — Latvian
  • lt — Lithuanian
  • tg — Tajik
  • fa — Persian
  • vi — Vietnamese
  • hy — Armenian
  • az — Azerbaijani
  • eu — Basque
  • hsb — Upper Sorbian
  • mk — Macedonian
  • tn — Tswana
  • xh — Xhosa
  • zu — Zulu
  • af — Afrikaans
  • ka — Georgian
  • fo — Faroese
  • hi — Hindi
  • mt — Maltese
  • se — Northern Sami
  • ga — Irish
  • ms — Malay (macrolanguage)
  • kk — Kazakh
  • ky — Kirghiz
  • sw — Swahili (macrolanguage)
  • tk — Turkmen
  • uz — Uzbek
  • tt — Tatar
  • bn — Bengali
  • pa — Panjabi
  • gu — Gujarati
  • or — Oriya
  • ta — Tamil
  • te — Telugu
  • kn — Kannada
  • ml — Malayalam
  • as — Assamese
  • mr — Marathi
  • sa — Sanskrit
  • mn — Mongolian
  • bo — Tibetan
  • cy — Welsh
  • km — Central Khmer
  • lo — Lao
  • gl — Galician
  • kok — Konkani (macrolanguage)
  • syr — Syriac
  • si — Sinhala
  • iu — Inuktitut
  • am — Amharic
  • tzm — Central Atlas Tamazight
  • ne — Nepali
  • fy — Western Frisian
  • ps — Pushto
  • fil — Filipino
  • dv — Dhivehi
  • ha — Hausa
  • yo — Yoruba
  • quz — Cusco Quechua
  • nso — Pedi
  • ba — Bashkir
  • lb — Luxembourgish
  • kl — Kalaallisut
  • ig — Igbo
  • ii — Sichuan Yi
  • arn — Mapudungun
  • moh — Mohawk
  • br — Breton
  • ug — Uighur
  • mi — Maori
  • oc — Occitan (post 1500)
  • co — Corsican
  • gsw — Swiss German
  • sah — Yakut
  • qut — Guatemala
  • rw — Kinyarwanda
  • wo — Wolof
  • prs — Dari
  • gd — Scottish Gaelic

The second argument accepts an object with a few properties — localeMatcher, style, unitDisplay, currency,useGrouping, minimumIntegerDigits, minimumFractionDigits, maximumFractionDigits, minimumSignificantDigits, and maximumSignificantDigits .

The localeMatcher option specifies the locale matching algorithm to use. The possible values are lookup and best fit . The lookup algorithm search for the locale until it finds the one that fits the character set of the strings that are being compared. best fit finds the locale that is at least but possibly more suited than the lookup algorithm.

The style option specifies the formatting style to use. Possible values for the style option include decimal , currency , percent , and unit . decimal is the default option and it’s used for plain number formatting, currency is for currency formatting, percent is for percent formatting, and unit is for unit formatting.

The currency option is for use in currency formatting. Possible values are ISO 4217 currency codes, such as USD for the US dollar and EUR for Euro. There’s no default value.

If the style property is set to currency then the currency property must be provided.

The currencyDisplay property sets how the currency is displayed in currency formatting. Possible values are symbol for adding localized currency symbol and it’s the default value, code is for adding the ISO currency code, name to use a localized currency name like ‘dollar’. useGrouping option is for setting the grouping separator to use for numbers, it’s a boolean value.

minimumIntegerDigits, minimumFractionDigits, and maximumFractionDigits are considered one group of options. minimumIntegerDigits specifies the minimum number of integer digits to use, ranging from 1 to 21, with 1 being the default option. minimumFractionDigits is the minimum number of fraction digits to use, ranging from 0 to 20.

The default is 0 for plain number and percent formatting. The default for currency formatting is specified by the ISO 4217 currency code list, and 2 if it’s not specified in the list. maximumFractionDigits is the maximum number of fraction digits to use, with possible values ranging from 0 to 20.

The default for a plain number is the maximum between minimumFractionDigits and 3. The default for currency formatting is the maximum between minimumFractionDigits and the number of fractional unit digits provided by the ISO 4217 currency code list or 2 if the list doesn’t provide that information. The default for percent formatting is the maximum between minimumFractionDigits and 0.

minimumSignificantDigits and maximumSignificantDigits are considered as another group of options. If at least one of the options in this group is defined, then the first group is ignored. minimumSignificantDigits is the minimum number of significant digits to use, with possible values ranging from 1 to 21 with the default being 1. maximumSignificantDigits is the maximum number of significant digits to use, with possible values ranging from 1 to 21 with the default being 21.

Some examples of formatting numbers include requiring a minimum number of digits for a number. We can do that with the constructor and the format method like the following:

console.log(new Intl.NumberFormat('en', {
  style: 'currency',
  currency: 'GBP',
  minimumIntegerDigits: 5
}).format(222));

Then we get £00,222.00 when we run the console.log function in the code above. We can also specify the minimum number of decimals after the decimal point with the minimumFractionDigits option like in the following code:

console.log(new Intl.NumberFormat('en', {
  style: 'currency',
  currency: 'GBP',
  minimumFractionDigits: 2
}).format(222));

Then we get £222.00 when we run the console.log function in the code above. We can change the grouping of the digits with the useGrouping option like in the code below:

console.log(new Intl.NumberFormat('hi', {
  style: 'decimal',
  useGrouping: true
}).format(22222222222));

We can see that we get 22,22,22,22,222 when we log the output of the code above. The hi locale is the Hindi locale, which formats numbers differently than English, and we can see that in Hindi, digits are grouped into groups of 2 when a number is bigger than one thousand.

Also, we can format numbers into non-Arab numerals. For example, if we want to display numbers in Chinese, we can set the nu option as the Unicode extension key of the locale string. For example, we can write:

console.log(new Intl.NumberFormat('zh-Hant-CN-u-nu-hanidec', {
  style: 'decimal',
  useGrouping: true
}).format(12345678));

Then we get ‘一二,三四五,六七八’ logged. The nu-hanidec specified the number system that we want to display the formatted number in. In the example above, we specified the number system to be the Chinese number system, so we displayed all the digits in Chinese.

Other Methods

In addition to the format method, the formatToParts and resolvedOptions methods are also available in the object created by the Intl.NumberFormat constructor. The formatToParts method returns the parts of the formatted number as an array. The resolvedOptions method returns a new object that has the options for formatting the number with properties reflecting the locale and collation options that are computed during the instantiation of the object.

To use the formatToParts method, we can write the following code:

console.log(new Intl.NumberFormat('hi', {
  style: 'decimal',
  useGrouping: true
}).`formatToParts`(22222222222));

Then we get:

[
  {
    "type": "integer",
    "value": "22"
  },
  {
    "type": "group",
    "value": ","
  },
  {
    "type": "integer",
    "value": "22"
  },
  {
    "type": "group",
    "value": ","
  },
  {
    "type": "integer",
    "value": "22"
  },
  {
    "type": "group",
    "value": ","
  },
  {
    "type": "integer",
    "value": "22"
  },
  {
    "type": "group",
    "value": ","
  },
  {
    "type": "integer",
    "value": "222"
  }
]

logged since the formatted number — 22,22,22,22,222 , is broken up into parts and put into the array and returned.

To use the resolvedOptions method, we can write the following code:

const formatOptions = new Intl.NumberFormat('hi', {
  style: 'decimal',
  useGrouping: true
}).resolvedOptions(22222222222)
console.log(formatOptions);

This will return:

{
  "locale": "hi",
  "numberingSystem": "latn",
  "style": "decimal",
  "minimumIntegerDigits": 1,
  "minimumFractionDigits": 0,
  "maximumFractionDigits": 3,
  "useGrouping": true,
  "notation": "standard",
  "signDisplay": "auto"
}

in the console.log output.The code above will get us all the options we used the format the number 22222222222 above.

JavaScript has the ability to format numbers for non-English users with Intl.NumberFormat constructor. This means that we can display numbers for people using non-English locales without adding another library to do this. We can format numbers with the Intl.NumberFormat constructor. The constructor takes 2 arguments.

The first argument is the locale string or an array of locale strings. Locale strings should be BCP 47 language tags with Unicode key extensions optionally attached. The object created by the constructor has a format method that takes a number that we want to format.

It will automatically group digits for different locales when we allow grouping or we can turn the grouping off, and we can specify the number of fractional digits, significant digits, and integer digits to display.

Categories
Vue

Truncate Text Easily in Your Vue.js App with Vue-Clamp

Long text often needs to be truncated to fit on the browser window. You can do that with CSS or JavaScript. However, there’s no quick solution with CSS. With CSS, you have to do something like:

.truncate {
  width: 500px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

This does not let you control how many lines of text you want to show, so we need a better solution. The Vue-Clamp package lets us truncate text to display the number of lines that we want. It updates automatically when you resize the window so you always get the number of lines displayed that you specify.

In this article, we will make a note taking app that lets users write notes, save them, and delete them. In the home page, we will use Vue-Clamp to truncate the text to only display the first 3 lines. There will be an edit form where the full text is displayed.

We start by creating the new project. To start, we run Vue CLI to create the project files. We run npx @vue/cli create note-app to start the wizard. Then in the wizard, we select ‘Manually select features’, and select Babel, CSS preprocessor, Vuex, and Vue Router.

Next, we install some packages. We need Axios to make HTTP requests to our back end, Bootstrap-Vue for styling, Vee-Validate for form validation, and Vue-Clamp for the text truncation. To install the packages, we run npm i axios bootstrap-vue vee-validate vue-clamp . After installing the packages we can start building our note-taking app.

First, we create our form for letting users take notes. In the components folder, create a file called NoteForm.vue and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group label="Name">
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.name"
            required
            placeholder="Name"
            name="name"
          ></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="Note">
        <ValidationProvider name="note" rules="required" v-slot="{ errors }">
          <b-form-textarea
            :state="errors.length == 0"
            v-model="form.note"
            required
            placeholder="Note"
            name="note"
            rows="10"
          ></b-form-textarea>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

      <b-button type="submit" variant="primary">Save</b-button>
    </b-form>
  </ValidationObserver>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "NoteForm",
  props: {
    note: Object,
    edit: Boolean
  },
  mixins: [requestsMixin],
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }

      if (this.edit) {
        await this.editNote(this.form);
      } else {
        await this.addNote(this.form);
      }
      const { data } = await this.getNotes();
      this.$store.commit("setNotes", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  data() {
    return {
      form: {}
    };
  },
  watch: {
    note: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

This form lets users search for dishes with the given keyword, then return a list of ingredients for the dishes and then the user can add them to a list with the duplicates removed. We use Vee-Validate to validate our inputs. We use the ValidationObserver component to watch for the validity of the form inside the component and ValidationProvider to check for the validation rule of the inputted value of the input inside the component. Inside the ValidationProvider , we have our BootstrapVue input for the text input fields. In the b-form-input components. We also add Vee-Validate validation to make sure that users have filled out the date before submitting. We make the name and note field required in the rules prop so that users will have to enter both to save the note.

We validate the values in the onSubmit function by running this.$refs.observer.validate() . If that resolves to true , then we run the code to save the data by calling the functions in the if block, then we call getNotes to get the notes. These functions are from the requestsMixin that we will add. The obtained data are stored in our Vuex store by calling this.$store.commit .

In this component, we also have a watch block to watch the note value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the note value is updated so that the latest can be edited by the user as we copy the values to this.form .

Next, we create a mixins folder and add requestsMixin.js into the mixins folder. In the file, we add:

const APIURL = "http://localhost:3000";
const axios = require("axios");

export const requestsMixin = {
  methods: {
    getNotes() {
      return axios.get(`${APIURL}/notes`);
    },

    addNote(data) {
      return axios.post(`${APIURL}/notes`, data);
    },

    editNote(data) {
      return axios.put(`${APIURL}/notes/${data.id}`, data);
    },

    deleteNote(id) {
      return axios.delete(`${APIURL}/notes/${id}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to our back end to save the notes.

Next in Home.vue , replace the existing code with:

<template>
  <div class="page">
    <b-button-toolbar>
      <b-button @click="openAddModal()">Add Note</b-button>
    </b-button-toolbar>

    <br />

    <b-card v-for="(n, i) in notes" :key="i" :title="n.name">
      <b-card-text class="note">
        <v-clamp autoresize :max-lines="3">{{n.note}}</v-clamp>
      </b-card-text>
      <b-button variant="primary" @click="openEditModal(n)">Edit</b-button>
      <b-button variant="warning" @click="deleteOneNote(n.id)">Delete</b-button>
    </b-card>

    <b-modal id="add-modal" title="Add Note" hide-footer>
      <NoteForm @saved="closeModal()" @cancelled="closeModal()" :edit="false"></NoteForm>
    </b-modal>

    <b-modal id="edit-modal" title="Edit Note" hide-footer>
      <NoteForm @saved="closeModal()" @cancelled="closeModal()" :edit="true" :note="selectedNote"></NoteForm>
    </b-modal>
  </div>
</template>

<script>
// @ is an alias to /src
import NoteForm from "@/components/NoteForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
import VClamp from "vue-clamp";

export default {
  name: "home",
  components: {
    NoteForm,
    VClamp
  },
  mixins: [requestsMixin],
  computed: {
    notes() {
      return this.$store.state.notes;
    }
  },
  beforeMount() {
    this.getAllNotes();
  },
  data() {
    return {
      selectedNote: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(note) {
      this.$bvModal.show("edit-modal");
      this.selectedNote = note;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedNote = {};
      this.getAllNotes();
    },
    async deleteOneNote(id) {
      await this.deleteNote(id);
      this.getAllNotes();
    },
    async getAllNotes() {
      const { data } = await this.getNotes();
      this.$store.commit("setNotes", data);
    }
  }
};
</script>

<style lang="scss" scoped>
.note {
  white-space: pre-wrap;
}
</style>

This is where we display the notes in BootstrapVue cards and have buttons to open an edit note modal or delete the note in each card. We also added an ‘Add Note’ button to open the modal to let users add a note. The notes are obtained from the back end by running the this.getAllNotes function in the beforeMount hook which stores the data in our Vuex store.

We use the v-clamp component, which is provided by the Vue-Clamp library to truncate long text to 3 lines. The autoresize prop will make it resize according to our screen size, so we never get more than 3 lines of text displayed. The openAddModal, openEditModal, closeModal open the open and close modals, and close the modal respectively. When openEditModal is called, we set the this.selectedNote variable so that we can pass it to our NoteForm .

Next in App.vue , we replace the existing code with:

<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand to="/">Note Taker App</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-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>

<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

<style lang="scss">
.page {
  padding: 20px;
}

button,
.btn.btn-primary {
  margin-right: 10px !important;
}

.button-toolbar {
  margin-bottom: 10px;
}
</style>

to add a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define. This style section isn’t scoped so the styles will apply globally. In the .page selector, we add some padding to our pages. We add some padding to the buttons in the remaining style code.

Then in main.js , replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";

extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

We added all the libraries we need here, including BootstrapVue JavaScript and CSS and Vee-Validate components along with the required validation rule here.

In router.js we replace the existing code with:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    }
  ]
});

to include the home page in our routes so users can see the page.

And in store.js , we replace the existing code with:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    notes: []
  },
  mutations: {
    setNotes(state, payload) {
      state.notes = payload;
    }
  },
  actions: {}
});

to add our notes state to the store so we can observe it in the computed block of NoteForm and HomePage components. We have the setNotes function to update the notes state and we use it in the components by call this.$store.commit(“setNotes”, data); like we did in NoteForm and HomePage .

Finally, in index.html , we replace the existing code with:

<!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>Note Taker App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-clampy-tutorial-app 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>

to change the title of our app.

After all the hard work, we can start our app by running npm run serve .

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "`notes`": []
}

So we have the notes endpoints defined in the requests.js available.

Categories
Vue

Add Money Inputs to Your Vue.js App with V-Money

Money input is handy for apps that need users to enter monetary amounts. Building an input component specifically for monetary input is tedious work. You have to add labels to indicate that it’s a money input, and you have to check that the input is valid. Some inputs also need prefixes, suffixes, and input masks, which makes building the input tougher. Fortunately, for Vue.js, there is the V-Money library, which is readily available for developers.

In this article, we will build a bill tracker app that tracks the expenses of the user. It lets users enter the name of the bill, the amount, and the due date, and also edit and delete them. To start building the project, we run the Vue CLI by running:

npx @vue/cli create bill-tracker

When the wizard runs, we select ‘Manually select features’, and select Babel, CSS preprocessor, Vuex, and Vue Router.

Next we install some packages. We need Axios to make HTTP requests to our back end, Bootstrap-Vue for styling, Vee-Validate for form validation, and V-Money for the money input. To install the packages, we run npm i axios bootstrap-vue vee-validate v-money. After installing the packages we can start building our bill tracker app.

First, we create our form for letting users add and edit their bills. In the components folder, create a file called BillForm.vue and add:

<template>
  <ValidationObserver ref="observer" v-slot="{ invalid }">
    <b-form @submit.prevent="onSubmit" novalidate>
      <b-form-group label="Name">
        <ValidationProvider name="name" rules="required" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.name"
            required
            placeholder="Name"
            name="name"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">Name is requied.</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

<b-form-group label="Amount">
        <ValidationProvider name="amount" rules="required|min_value:0" v-slot="{ errors }">
          <money
            v-model="form.amount"
            v-bind="money"
            class="form-control"
            :class="{'is-valid': errors.length == 0, 'is-invalid': errors.length > 0}"
          ></money>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

<b-form-group label="Due Date">
        <ValidationProvider name="dueDate" rules="required|date" v-slot="{ errors }">
          <b-form-input
            type="text"
            :state="errors.length == 0"
            v-model="form.dueDate"
            required
            placeholder="Due Date"
            name="dueDate"
          ></b-form-input>
          <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
        </ValidationProvider>
      </b-form-group>

<b-button type="submit" variant="primary" style="margin-right: 10px">Submit</b-button>
      <b-button type="reset" variant="danger" @click="cancel()">Cancel</b-button>
    </b-form>
  </ValidationObserver>
</template>

<script>
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "BillForm",
  mixins: [requestsMixin],
  props: {
    bill: Object,
    edit: Boolean
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }

const offDate = new Date(this.form.date);
      const correctedDate = new Date(
        offDate.getTime() + Math.abs(offDate.getTimezoneOffset() * 60000)
      );

const params = {
        ...this.form,
        date: correctedDate
      };

if (this.edit) {
        await this.editBill(params);
      } else {
        await this.addBill(params);
      }
      const { data } = await this.getBills();
      this.$store.commit("setBills", data);
      this.$emit("saved");
    },
    cancel() {
      this.$emit("cancelled");
    }
  },
  data() {
    return {
      form: {},
      money: {
        decimal: ".",
        thousands: ",",
        prefix: "$ ",
        precision: 2,
        masked: false
      }
    };
  },
  watch: {
    bill: {
      handler(val) {
        this.form = JSON.parse(JSON.stringify(val || {}));
      },
      deep: true,
      immediate: true
    }
  }
};
</script>

This form lets users search for dishes with the given keyword, then return a list of ingredients for the dishes and then the user can add them to a list with the duplicates removed. We use Vee-Validate to validate our inputs. We use the ValidationObserver component to watch for the validity of the form inside the component and ValidationProvider to check for the validation rule of the inputted value of the input inside the component. Inside the ValidationProvider , we have our BootstrapVue input for the text input fields. In the b-form-input components. We also add Vee-Validate validation to make sure that users have filled out the date before submitting it. We make the name , amount , and dueDate fields required in the rules prop so that users will have to enter all of them to save the bill.

For the amount field input, we use the money component from the V-Money library to add the money input for the bill amount. The v-bind directive is used for setting the options for the money input. The money object in the data field has the options. We specified that the decimal point is marked by a period, a comma separates every 3 digits with the thousands option, and prefix the amount with a dollar sign to make the input clear. precision 2 means that we let users enter up to 2 digits, and masked false means that we disabled the input mask. We applied our CSS classes for Bootstrap directly on the money component so that we make the styles consistent with the other 2 inputs. Both static and dynamic classes work properly with the money component.

We validate the values in the onSubmit function by running this.$refs.observer.validate() . If that resolves to true , then we run the code to save the data by calling the functions in the if block, then we call getNotes to get the notes. These functions are from the requestsMixin that we will add. The obtained data are stored in our Vuex store by calling this.$store.commit .

In this component, we also have a watch block to watch the bill value, which is obtained from the Vuex store that we have to build. We get the latest list of ingredients as the billvalue is updated so that the latest can be edited by the user as we copy the values to this.form .

Next, we create a mixins folder and add requestsMixin.js into the mixins folder. In the file, we add:

const APIURL = "http://localhost:3000";
const axios = require("axios");

export const requestsMixin = {
  methods: {
    getBills() {
      return axios.get(`${APIURL}/bills`);
    },

    addBill(data) {
      return axios.post(`${APIURL}/bills`, data);
    },

    editBill(data) {
      return axios.put(`${APIURL}/bills/${data.id}`, data);
    },

    deleteBill(id) {
      return axios.delete(`${APIURL}/bills/${id}`);
    }
  }
};

These are the functions we use in our components to make HTTP requests to our back end to save the bills.

Next in Home.vue , replace the existing code with:

<template>
  <div class="page">
    <h1 class="text-center">Bill Tracker</h1>
    <b-button-toolbar>
      <b-button @click="openAddModal()">Add Bill</b-button>
    </b-button-toolbar>
    <br />
    <b-table-simple responsive>
      <b-thead>
        <b-tr>
          <b-th>Name</b-th>
          <b-th>Amount</b-th>
          <b-th>Due Date</b-th>
          <b-th></b-th>
          <b-th></b-th>
        </b-tr>
      </b-thead>
      <b-tbody>
        <b-tr v-for="b in bills" :key="b.id">
          <b-td>{{b.name}}</b-td>
          <b-td>${{b.amount}}</b-td>
          <b-td>{{b.dueDate}}</b-td>
          <b-td>
            <b-button @click="openEditModal(b)">Edit</b-button>
          </b-td>
          <b-td>
            <b-button @click="deleteOneBill(b.id)">Delete</b-button>
          </b-td>
        </b-tr>
      </b-tbody>
    </b-table-simple>

    <b-modal id="add-modal" title="Add Bill" hide-footer>
      <BillForm @saved="closeModal()" @cancelled="closeModal()" :edit="false"></BillForm>
    </b-modal>

    <b-modal id="edit-modal" title="Edit Bill" hide-footer>
      <BillForm @saved="closeModal()" @cancelled="closeModal()" :edit="true" :bill="selectedBill"></BillForm>
    </b-modal>
  </div>
</template>

<script>
import BillForm from "@/components/BillForm.vue";
import { requestsMixin } from "@/mixins/requestsMixin";

export default {
  name: "home",
  components: {
    BillForm
  },
  mixins: [requestsMixin],
  computed: {
    bills() {
      return this.$store.state.bills;
    }
  },
  beforeMount() {
    this.getAllBills();
  },
  data() {
    return {
      selectedBill: {}
    };
  },
  methods: {
    openAddModal() {
      this.$bvModal.show("add-modal");
    },
    openEditModal(bill) {
      this.$bvModal.show("edit-modal");
      this.selectedBill = bill;
    },
    closeModal() {
      this.$bvModal.hide("add-modal");
      this.$bvModal.hide("edit-modal");
      this.selectedBill = {};
      this.getAllBills();
    },
    async deleteOneBill(id) {
      await this.deleteBill(id);
      this.getAllBills();
    },
    async getAllBills() {
      const { data } = await this.getBills();
      this.$store.commit("setBills", data);
    }
  }
};
</script>

This is where we display the bills in a BootstrapVue table. The columns are the name, the amount, and the due date, along with the Edit button to open the edit modal, and Delete button to delete an entry when it’s clicked. We also added an ‘Add Bill’ button to open the modal to let users add a bill. The notes are obtained from the back end by running the this.getAllBills function in the beforeMount hook which stores the data in our Vuex store.

The openAddModal, openEditModal, closeModal open the open and close modals, and close the modal respectively. When openEditModal is called, we set the this.selectedNote variable so that we can pass it to our NoteForm .

Next in App.vue , we replace the existing code with:

<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand to="/">Bill 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-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>

<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>

<style lang="scss">
.page {
  padding: 20px;
}

button,
.btn.btn-primary {
  margin-right: 10px !important;
}

.button-toolbar {
  margin-bottom: 10px;
}
</style>

to add a Bootstrap navigation bar to the top of our pages, and a router-view to display the routes we define. This style section isn’t scoped so the styles will apply globally. In the .page selector, we add some padding to our pages. We add some padding to the buttons in the remaining style code.

Then in main.js , replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import money from "v-money";
import VueFilterDateFormat from "vue-filter-date-format";
import BootstrapVue from "bootstrap-vue";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required, min_value } from "vee-validate/dist/rules";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";

extend("required", required);
extend("min_value", min_value);
extend("date", {
  validate: value => {
    return /([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))/.test(value);
  },
  message: "Date must be in YYYY-MM-DD format."
});
Vue.use(BootstrapVue);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(money, { precision: 4 });
Vue.use(VueFilterDateFormat);

Vue.config.productionTip = false;

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

We added all the libraries we need here, including BootstrapVue JavaScript and CSS and Vee-Validate components along with the required validation rule and a date rule for validating that the due date is YYYY-MM-DD format.

In router.js we replace the existing code with:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";

Vue.use(Router);

export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    }
  ]
});

to include the home page in our routes so users can see the page.

And in store.js , we replace the existing code with:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    bills: []
  },
  mutations: {
    setBills(state, payload) {
      state.bills = payload;
    }
  },
  actions: {}
});

to add our bills state to the store so we can observe it in the computed block of BillForm and HomePage components. We have the setBills function to update the notes state and we use it in the components by call this.$store.commit(“setBills”, data); like we did in BillForm and HomePage .

Finally, in index.html , we replace the existing code with:

<!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>Bill Tracker</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but v-money-tutorial-app 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>

to change the title of our app.

After all the hard work, we can start our app by running npm run serve.

To start the back end, we first install the json-server package by running npm i json-server. Then, go to our project folder and run:

json-server --watch db.json

In db.json, change the text to:

{
  "`bills`": []
}

So we have the bills endpoints defined in the requests.js available.

Categories
Quasar

Developing Vue Apps with the Quasar Library — Sticky Table and Cell Separators

Quasar is a popular Vue UI library for developing good looking Vue apps.

In this article, we’ll take a look at how to create Vue apps with the Quasar UI library.

Sticky Column

We can make the first column sticky by applying some CSS styles.

To do this, we write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <style>
      .sticky-column-table {
        max-width: 600px;
      }
      .sticky-column-table thead tr:first-child th:first-child {
        background-color: yellow;
      }
      .sticky-column-table td:first-child {
        background-color: yellow;
      }
      .sticky-column-table th:first-child,
      .sticky-column-table td:first-child {
        position: sticky;
        left: 0;
        z-index: 1;
      }
    </style>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table
            class="sticky-column-table"
            title="Treats"
            :data="data"
            row-key="name"
          >
          </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];
      new Vue({
        el: "#q-app",
        data: {
          data
        }
      });
    </script>
  </body>
</html>

We can make both the first column and table heading sticky with some CSS styles.

To do this, we write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <style>
      .sticky-header-column-table {
        height: 310px;
        max-width: 600px;
      }
      .sticky-header-column-table td:first-child {
        background-color: orange !important;
      }
      .sticky-header-column-table tr th {
        position: sticky;
        z-index: 2;
        background: #fff;
      }
      .sticky-header-column-table thead tr:last-child th {
        top: 48px;
        z-index: 3;
      }
      .sticky-header-column-table thead tr:first-child th {
        top: 0;
        z-index: 1;
      }
      .sticky-header-column-table tr:first-child th:first-child {
        z-index: 3;
      }
      .sticky-header-column-table td:first-child {
        z-index: 1;
      }
      .sticky-header-column-table td:first-child,
      .sticky-header-column-table th:first-child {
        position: sticky;
        left: 0;
      }
    </style>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table
            class="sticky-header-column-table"
            title="Treats"
            :data="data"
            row-key="name"
          >
          </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];
      new Vue({
        el: "#q-app",
        data: {
          data
        }
      });
    </script>
  </body>
</html>

We set position to sticky on both the first td and first th .

And the z-index of the th and tr to be above 0 show the th above the other elements.

Separator

We can set the separator prop to set the border of the cells.

For example, we can write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>

    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table
            class="sticky-header-column-table"
            title="Treats"
            :data="data"
            row-key="name"
            separator="vertical"
          >
          </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];
      new Vue({
        el: "#q-app",
        data: {
          data
        }
      });
    </script>
  </body>
</html>

To set the separator prop to vertical to add borders to the columns and the headings.

Other values include horizontal to add borders for the rows, cell to add borders to all cells, and none to remove the separators.

Conclusion

We can add sticky headers and columns with some CSS and add separators for cells with Quasar’s q-table component.

Categories
Quasar

Developing Vue Apps with the Quasar Library —Table Heading

Quasar is a popular Vue UI library for developing good looking Vue apps.

In this article, we’ll take a look at how to create Vue apps with the Quasar UI library.

Omit Column Definition

Quasar’s q-table component can infer the column headings from the data.

For instance, we can write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table title="Treats" :data="data" row-key="name"> </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];
      new Vue({
        el: "#q-app",
        data: {
          data
        }
      });
    </script>
  </body>
</html>

Then the property names will be capitalized to form the column headings.

Sticky Header

We can make the header sticky with some CSS styles.

To make a header sticky, we write:

<!DOCTYPE html>
<html>
  <head>
    <link
      href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons"
      rel="stylesheet"
      type="text/css"
    />
    <link
      href="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.min.css"
      rel="stylesheet"
      type="text/css"
    />
  </head>
  <body class="body--dark">
    <script src="https://cdn.jsdelivr.net/npm/vue@^2.0.0/dist/vue.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/quasar@1.12.13/dist/quasar.umd.min.js"></script>
    <style>
      .sticky-header-table {
        height: 300px;
      }
      .sticky-header-table .q-table__top,
      .sticky-header-table .q-table__bottom,
      .sticky-header-table thead tr:first-child th {
        background-color: yellow;
      }
      .sticky-header-table thead tr th {
        position: sticky;
        z-index: 1;
      }
      .sticky-header-table thead tr:first-child th {
        top: 0;
      }
      .sticky-header-table.q-table--loading thead tr:last-child th {
        top: 48px;
      }
    </style>
    <div id="q-app">
      <q-layout
        view="lHh Lpr lFf"
        container
        style="height: 100vh;"
        class="shadow-2 rounded-borders"
      >
        <div class="q-pa-md">
          <q-table
            class="sticky-header-table"
            title="Treats"
            :data="data"
            row-key="name"
          >
          </q-table>
        </div>
      </q-layout>
    </div>
    <script>
      const data = [
        {
          name: "Frozen Yogurt",
          calories: 159,
          fat: 6.0,
          calcium: "14%"
        },
        {
          name: "Ice cream sandwich",
          calories: 237,
          fat: 9.0,
          calcium: "8%"
        },
        {
          name: "Eclair",
          calories: 262,
          fat: 16.0,
          calcium: "6%"
        },
        {
          name: "Honeycomb",
          calories: 408,
          fat: 3.2,
          calcium: "0%"
        },
        {
          name: "Donut",
          calories: 452,
          fat: 25.0,
          calcium: "2%"
        },
        {
          name: "KitKat",
          calories: 518,
          fat: 26.0,
          calcium: "12%"
        }
      ];
      new Vue({
        el: "#q-app",
        data: {
          data
        }
      });
    </script>
  </body>
</html>

We set the style of the th element to position: sticky to make the header sticky.

Also, we set the height of the table to 300px so that the content inside is scrollable.

Conclusion

We can apply various styles to our tables to make the heading sticky.

Also, we can add a Quasar table without an explicit column definition.