Quasar - Utility Belt App Extension to speedup the development of SSR and offline first apps.
Tobias Mesquita
Posted on September 10, 2019
Table Of Contents
- 1 Introduction
- 2 Installing
- 3 Timer
- 4 UUID
- 5 Store
- 6 Factories
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"
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))
}
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())
}
outputs:
start: {"time":1567290656567,"iso":"2019-08-31T22:30:56.567Z"}
end: {"time":1567290661570,"iso":"2019-08-31T22:31:01.570Z"}
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)))
}
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"}
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))
}
}
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"}
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 }
Expected Output
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
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 }
Expected Output
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
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() })
}
}
}
<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>
Expected Output
console
mounted page
getting record/doc by your id
item removed
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)
}
Expected Output
<q-input type="text" label="Text" reverse v-model="text" />
<q-input type="number" label="Number" v-model.number="number" />
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)
}
}
Now, we're rebranding QInput
, QSelect
, QField
, QMarkupTable
, QList
and QItem
Expected Output
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 }
Expected Output
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
Expected Output
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
Expected Output
console - without initialize action
console - with initialize action
rendered page
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
September 10, 2019