Quasar - Utility Belt App Extension to speedup the development of SSR and offline first apps.

tobymosque

Tobias Mesquita

Posted on September 10, 2019

Quasar - Utility Belt App Extension to speedup the development of SSR and offline first apps.

Table Of Contents

1 Introduction

I developed a small app extension with the goal to speedup the development/prototyping of the SSR and offline first apps. I'll try to explain all the helpers, how to use them and what problem they try to solve.

2 Installing

quasar ext add "@toby.mosque/utils"
Enter fullscreen mode Exit fullscreen mode

Remember, the full package name is @toby.mosque/quasar-app-extension-utils, but for convenience, the extension will register @toby.mosque/utils as an alias to @toby.mosque/quasar-app-extension-utils/src/utils.

3 Timer

3.1 Sleep

time.sleep is as simple as:

export default function (delay) {
  return new Promise(resolve => setTimeout(resolve, delay))
}
Enter fullscreen mode Exit fullscreen mode

the goal is to enable the dev to suspend an async method for sometime.

import { timer } from '@toby.mosque/utils'

const timestamp = function (date) {
  date = date || new Date()
  return JSON.stringify({ time: date.getTime(), iso: date.toISOString() })
}

export default async (context) => {
  console.log('start: ', timestamp())
  await timer.sleep(5000)
  console.log('end: ', timestamp())
}
Enter fullscreen mode Exit fullscreen mode

outputs:

start:  {"time":1567290656567,"iso":"2019-08-31T22:30:56.567Z"}
end:    {"time":1567290661570,"iso":"2019-08-31T22:31:01.570Z"}
Enter fullscreen mode Exit fullscreen mode

4 UUID

A common issue that happens when you use UUID/GUID as primary key, is the order of insertion of your records/docs becomes unpredictable, that can potentially slow down the insertion of new records/docs, or even make the order by id a problem to the UX, since the records/docs will look like it's been shuffled around.

4.1 comb

The comb method is inspired by the C# lib RT.Comb, but I implemented only the Postgre provider, that replaces the first 6 bytes of the UUID with the current date.

4.1.1 parameters

order name required default description
1 date false new Date() date used to create the COMB

4.2 extractDate

The extractDate method allows the dev to retrieve the date used to create the COMB.

4.2.1 parameters

order name required default description
1 comb true comb to have date component exracted

4.3 Demos

4.3.1 Basic Usage

// import something here
import { uuid } from '@toby.mosque/utils'

// "async" is optional
export default async (context) => {
  let date = new Date(2019, 8, 1, 17, 25, 40, 123)
  let comba = uuid.comb()
  let combb = uuid.comb(date)
  console.log('comb a: ', comba)
  console.log('comb b: ', combb)
  console.log('date a: ', timestamp(uuid.extractDate(comba)))
  console.log('date b: ', timestamp(uuid.extractDate(combb)))
}
Enter fullscreen mode Exit fullscreen mode

outputs

comb a:  016ce9d4-cb8f-4b88-b049-8569e9bec009
comb b:  016cee81-321b-f5ee-92ab-fdafda9612fe
date a:  {"time":1567291132815,"iso":"2019-08-31T22:38:52.815Z"}
date b:  {"time":1567369540123,"iso":"2019-09-01T20:25:40.123Z"}
Enter fullscreen mode Exit fullscreen mode

4.3.2 Sorting

import { uuid, timer } from '@toby.mosque/utils'

// "async" is optional
export default async (context) => {
  let combs = []
  for (let i = 1; i <= 10; i++) {
    await timer.sleep(1)
    combs.push({ index: i, value: uuid.comb() })
  }

  combs.sort((a, b) => a.value > b.value ? 1 : -1)
  console.log('ascending:')
  for (let item of combs) {
    console.log(JSON.stringify(item))
  }
  combs.sort((a, b) => a.value < b.value ? 1 : -1)
  console.log('descending: ')
  for (let item of combs) {
    console.log(JSON.stringify(item))
  }
}
Enter fullscreen mode Exit fullscreen mode

outputs

ascending:
{"index":1,"value":"016ce9dc-9422-be1b-d70f-8769fa62380c"}
{"index":2,"value":"016ce9dc-94e1-40e4-5a16-7db2c0fb109d"}
{"index":3,"value":"016ce9dc-94e3-1316-8633-23694b929895"}
{"index":4,"value":"016ce9dc-94e5-04e3-d043-2b147af04f19"}
{"index":5,"value":"016ce9dc-98ca-8307-1ea1-54a488a1195c"}
{"index":6,"value":"016ce9dc-9cb2-468b-23d6-3bbe47c70539"}
{"index":7,"value":"016ce9dc-a099-fc93-8edf-68f3c00b4fcc"}
{"index":8,"value":"016ce9dc-a482-5f80-b40d-a64b0e8a23a9"}
{"index":9,"value":"016ce9dc-a86a-d6c5-1359-ce0c7b9d053e"}
{"index":10,"value":"016ce9dc-abc4-7d81-e27b-9f1868a48be8"}
descending: 
{"index":10,"value":"016ce9dc-abc4-7d81-e27b-9f1868a48be8"}
{"index":9,"value":"016ce9dc-a86a-d6c5-1359-ce0c7b9d053e"}
{"index":8,"value":"016ce9dc-a482-5f80-b40d-a64b0e8a23a9"}
{"index":7,"value":"016ce9dc-a099-fc93-8edf-68f3c00b4fcc"}
{"index":6,"value":"016ce9dc-9cb2-468b-23d6-3bbe47c70539"}
{"index":5,"value":"016ce9dc-98ca-8307-1ea1-54a488a1195c"}
{"index":4,"value":"016ce9dc-94e5-04e3-d043-2b147af04f19"}
{"index":3,"value":"016ce9dc-94e3-1316-8633-23694b929895"}
{"index":2,"value":"016ce9dc-94e1-40e4-5a16-7db2c0fb109d"}
{"index":1,"value":"016ce9dc-9422-be1b-d70f-8769fa62380c"}
Enter fullscreen mode Exit fullscreen mode

5 Store

If you're developing an SSR or offline first app, you're probably overusing Vuex modules; and I know, you don't have much choice here. So let's try make parts of the module definition more reusable.

5.1 mapStoreMutations

mapStoreMutations maps all classes fields to a mutations-like object.

5.1.1 parameters

order name required default description
1 model true class used to model the mutations object

5.1.2 Demos

5.1.2.1 Basic Usage
import { store } from '@toby.mosque/utils'
const { mapStoreMutations } = store

class Model {
  text = ''
  number = 0
  collection = []
}

const storeModule = {
  namespaced: true,
  state () {
    return new Model()
  },
  mutations: {
    ...mapStoreMutations(Model)
  }
}
console.log(storeModule)
export default storeModule
export { Model }
Enter fullscreen mode Exit fullscreen mode

Expected Output

Alt Text

5.1.3 Scenario without the helper

This is what you would need to write to create the above module without the using the mapStoreMutations helper

const storeModule = {
  namespaced: true,
  state () {
    return {
      text: '',
      number: 0,
      collection: []
    }
  },
  mutations: {
    text (state, value) { state.text = value },
    number (state, value) { state.number = value },
    collection (state, value) { state.collection = value }
  }
}
console.log(storeModule)
export default storeModule
Enter fullscreen mode Exit fullscreen mode

5.2 mapStoreCollections

Here the goal is create mutations (create, update, delete), actions (upsert, delete) and getters (index, getById) related to array fields.

5.2.1 parameters

order name required default description
1 collections true an array of objects that describes your collection

5.2.1.1 Collection info object

field required ddescription
single true the single form of the collection (item, person, job)
plural true the plural form of the collection (list, people, jobs), would be the same as in the state
id true the name of the id field of the object in the collection

5.2.2 Demos

5.2.2.1 Basic Usage
import { store } from '@toby.mosque/utils'
const { mapStoreMutations, mapStoreCollections } = store

class Model {
  text = ''
  number = 0
  collection = []
}

const collection = mapStoreCollections([
  { single: 'item', plural: 'collection', id: 'id' }
])

const storeModule = {
  namespaced: true,
  state () {
    return new Model()
  },
  mutations: {
    ...mapStoreMutations(Model),
    ...collection.mutations
  },
  actions: {
    ...collection.actions
  },
  getters: {
    ...collection.getters
  }
}
console.log(storeModule)
export default storeModule
export { Model }
Enter fullscreen mode Exit fullscreen mode

Expected Output

Alt Text

5.2.3 Scenario without the helper

This is what you would need to write in order to create the above module without the help of the mapStoreMutations and mapStoreCollections helpers

import Vue from 'vue'

const storeModule = {
  namespaced: true,
  state () {
    return {
      text: '',
      number: 0,
      collection: []
    }
  },
  mutations: {
    text (state, value) { state.text = value },
    number (state, value) { state.number = value },
    collection (state, value) { state.collection = value },
    updateItem (state, { index, item }) { Vue.set(state.collection, index, item) },
    createItem (state, item) { state.collection.push(item) },
    deleteItem (state, index) { Vue.delete(state.collection, index) }
  },
  actions: {
    deleteItem ({ commit, getters }, id) {
      let index = getters.collectionIndex.get(id)
      if (index !== void 0) {
        commit('deleteItem', index)
      }
    },
    saveOrUpdateItem ({ commit, getters }, item) {
      let index = getters.collectionIndex.get(item.id)
      if (index !== void 0) {
        commit('updateItem', { index, item })
      } else {
        commit('createItem', item)
      }
    }
  },
  getters: {
    collectionIndex (state) {
      return state.collection.reduce((map, item, index) => {
        map.set(item.id, index)
        return map
      }, new Map())
    },
    itemById (state, getters) {
      return (id) => {
        let index = getters.collectionIndex.get(id)
        return index !== void 0 ? state.collection[index] : void 0
      }
    }
  }
}
console.log(storeModule)
export default storeModule
Enter fullscreen mode Exit fullscreen mode

5.3 mapState

I don't know if you noticed, but our store now has a pattern, where the state fields and mutations names have the same name and that has a reason.

We need both to have the same name, in order to map them to a computed property, to be exact, the state will be mapped to a get, and the mutation to a set.

5.3.1 Parameters

The parameters will be very much like the Vuex mapState's parameters.

order name required default description
1 moduleName true the module name
2 fields true fields can be an array of strings or a object where the keys and values are strings. e.g: ['text', number, 'list'] or { text: 'text', number: 'number', collection: 'list' }

5.3.2 Demos

5.3.2.1 Basic Usage
import { store, uuid, timer } from '@toby.mosque/utils'
import { Model } from 'src/store/test'
import { mapActions, mapGetters } from 'vuex'
const { mapState } = store

export default {
  name: 'TestPage',
  computed: {
    ...mapState('test', Object.keys(new Model())),
    ...mapGetters('test', ['itemById'])
  },
  methods: {
    ...mapActions('test', ['saveOrUpdateItem', 'deleteItem']),
    remove (id) {
      let item = this.itemById(id)
      this.$q.dialog({
        parent: this,
        message: `delete ${item.index}:${item.date}?`
      }).onOk(() => {
        this.deleteItem(id)
      })
    }
  },
  async mounted () {
    this.text = 'Hello World'
    this.number = 123
    for (let i = 1; i <= 10; i++) {
      await timer.sleep(1)
      let comb = uuid.comb()
      this.saveOrUpdateItem({ id: comb, index: i, date: uuid.extract(comb).toISOString() })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
<template>
  <q-page class="q-pa-md q-gutter-y-md">
    <q-input type="text" v-model="text" />
    <q-input type="number" v-model.number="number" />
    <q-markup-table>
      <thead>
        <tr>
          <th class="text-left">Id</th>
          <th class="text-left">Index</th>
          <th class="text-left">Date</th>
          <th class="text-left">Actions</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="item in collection" :key="item.id">
          <td class="text-left">{{item.id}}</td>
          <td class="text-left">{{item.index}}</td>
          <td class="text-left">{{item.date}}</td>
          <td class="text-left">
            <q-btn icon="delete" label="Delete" @click="remove(item.id)" />
          </td>
        </tr>
      </tbody>
    </q-markup-table>
  </q-page>
</template>

<style>
</style>

<script src="./Index.vue.js">
</script>
Enter fullscreen mode Exit fullscreen mode

Expected Output

console

Alt Text

mounted page

Alt Text

getting record/doc by your id

Alt Text

item removed

Alt Text

6 Factories

the primary goal here, is wrapping components or generate the whole store and/or pages.

6.1 component

the factory.component objective is wrapper a other component and allow the dev to modify them at the render time, or even setup your properties, slots, etc.

6.1.1 Parameters

order name required default description
1 options true options used to generate the component
6.1.1.1 Options
field required description
name true component's name
component true component to be wrapped
render false callback function that is called everytime the component is rendered: render ({ self, options }) { }
render false callback function that is called when the component is ready to be returned: setup({ component }) { }
factories false array of objects with a render and/or setup field

6.1.2 Demos

6.1.2.1 Basic Usage

We'll inject the reverse property in the QInput and when that property is set as true the label will be inverted. Beside that, we'll set the dark and outlined property to true. The QMarkupTable will also have the dark property changed to true

// import something here
import { factory } from '@toby.mosque/utils'
import { QInput, QMarkupTable } from 'quasar'
const { component } = factory

// "async" is optional
export default async ({ Vue }) => {
  let input = component({
    name: QInput.options.name,
    component: QInput,
    // eslint-disable-next-line vue/require-render-return
    render ({ self, options }) {
      options.props.dark = true
      options.props.outlined = true
      if (options.props.label && options.props.reverse) {
        options.props.label = options.props.label.split('').reverse().join('')
      }
    },
    setup ({ component }) {
      component.props.reverse = Boolean
    }
  })

  let markupTable = component({
    name: QMarkupTable.options.name,
    component: QMarkupTable,
    // eslint-disable-next-line vue/require-render-return
    render ({ self, options }) {
      options.props.dark = true
    }
  })

  Vue.component('q-input', input)
  Vue.component('q-markup-table', markupTable)
}
Enter fullscreen mode Exit fullscreen mode

Expected Output

<q-input type="text" label="Text" reverse v-model="text" />
<q-input type="number" label="Number" v-model.number="number" />
Enter fullscreen mode Exit fullscreen mode

Alt Text

6.1.2.2 More Reusable

Ok, we had only 2 components and we already had a lot of boilerplate. We can improve that:

import { factory } from '@toby.mosque/utils'
import { QInput, QSelect, QField, QMarkupTable, QList, QItem } from 'quasar'
const { component } = factory
const factories = {}

factories.common = {
  render ({ self, options }) {
    options.props.dark = true
  }
}

factories.field = {
  render ({ self, options }) {
    options.props.outlined = true
    if (options.props.label && options.props.reverse) {
      options.props.label = options.props.label.split('').reverse().join('')
    }
  },
  setup ({ component }) {
    component.props.reverse = Boolean
  }
}

const fields = [
  { name: 'q-input', component: QInput },
  { name: 'q-select', component: QSelect },
  { name: 'q-field', component: QField }
]
const layout = [
  { name: 'q-markup-table', component: QMarkupTable },
  { name: 'q-list', component: QList },
  { name: 'q-item', component: QItem }
]

// "async" is optional
export default async ({ Vue }) => {
  for (let item of fields) {
    let branded = component({
      name: item.component.options.name,
      component: item.component,
      factories: [ factories.common, factories.field ]
    })
    Vue.component(item.name, branded)
  }

  for (let item of layout) {
    let branded = component({
      name: item.component.options.name,
      component: item.component,
      factories: [ factories.common ]
    })
    Vue.component(item.name, branded)
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, we're rebranding QInput, QSelect, QField, QMarkupTable, QList and QItem

Expected Output

Alt Text

6.2 Store

factory.store combines store.mapStoreMutations and store.mapStoreCollections.

6.2.1 Parameters

order name required default description
1 options true options used to generate the store
2 state false module's state, that will be merged intro the final module
3 mutations false module's mutations, that will be merged intro the final module
4 actions false module's actions, that will be merged intro the final module
5 getters false module's getters, that will be merged intro the final module
6.2.1.1 options
field required description
model false class used to model the state and mutations
collections false an array of objects that describes your collection (see store.mapStoreMutations for more details)

6.2.2 Demos

6.2.2.1 Basic Usage
import { factory } from '@toby.mosque/utils'
const { store } = factory

const options = {
  model: class Model {
    text = ''
    number = 0
    collection = []
  },
  collections: [
    { single: 'item', plural: 'collection', id: 'id' }
  ]
}

const storeModule = store({
  options
})
console.log(storeModule)
export default storeModule
export { options }
Enter fullscreen mode Exit fullscreen mode

Expected Output

Alt Text

6.3 Page

factory.page will expect the same options as factory.store and will map the state, mutations, actions and getters generated by factory.store to the page.

6.3.1 Parameters

order name required default description
1 options true options used to generate the page
2 moduleName true the name of the module
3 storeModule false if not null, it'll be registered in the preFetch or in the mounted hook, and removed in the destroyed hook.
4 * false any other property will be copied to the page as is
6.3.1.1 options
field required description
model false class used to modeling the state and mutations
collections false an array of objects that describes the your collection (see store.mapStoreMutations for more details)

6.3.2 Demos

6.3.2.1 Basic Usage
import { factory, uuid, timer } from '@toby.mosque/utils'
import { options } from 'src/store/test'
const { page } = factory

let testPage = page({
  name: 'TestPage',
  options,
  moduleName: 'test',
  methods: {
    remove (id) {
      let item = this.itemById(id)
      this.$q.dialog({
        parent: this,
        message: `delete ${item.index}:${item.date}?`
      }).onOk(() => {
        this.deleteItem(id)
      })
    }
  },
  async mounted () {
    this.text = 'Hello World'
    this.number = 123
    for (let i = 1; i <= 10; i++) {
      await timer.sleep(1)
      let comb = uuid.comb()
      this.saveOrUpdateItem({ id: comb, index: i, date: uuid.extract(comb).toISOString() })
    }
  }
})

console.log(testPage)
export default testPage
Enter fullscreen mode Exit fullscreen mode

Expected Output

Alt Text

6.4 Page and Store

Let's combine factory.page with factory.store and see what happens.

6.4.1 Demos

6.4.1.1 Basic Usage

Pay particular attention to the initialize action, it receives the route as an argument and is called as soon as the store is registered (perfect place to load data intro the state).

import { factory, uuid, timer } from '@toby.mosque/utils'
const { store, page } = factory

const options = {
  model: class Model {
    text = ''
    number = 0
    collection = []
  },
  collections: [
    { single: 'item', plural: 'collection', id: 'id' }
  ]
}

const storeModule = store({
  options,
  actions: {
    async initialize ({ commit, dispatch }, { route }) {
      commit('text', 'Hello World')
      commit('number', 123)
      this.number = 123
      for (let i = 1; i <= 10; i++) {
        await timer.sleep(1)
        let comb = uuid.comb()
        await dispatch('saveOrUpdateItem', { id: comb, index: i, date: uuid.extract(comb).toISOString() })
      }
    }
  }
})

let testPage = page({
  name: 'TestPage',
  options,
  moduleName: 'test',
  storeModule,
  methods: {
    remove (id) {
      let item = this.itemById(id)
      this.$q.dialog({
        parent: this,
        message: `delete ${item.index}:${item.date}?`
      }).onOk(() => {
        this.deleteItem(id)
      })
    }
  }
})

console.log(testPage, storeModule)
export default testPage

Enter fullscreen mode Exit fullscreen mode

Expected Output

console - without initialize action

Alt Text

console - with initialize action

Alt Text

rendered page

Alt Text

💖 💪 🙅 🚩
tobymosque
Tobias Mesquita

Posted on September 10, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related