├── .babelrc ├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── example ├── app.vue ├── components │ ├── countries.vue │ ├── single.vue │ └── todos.vue ├── index.html ├── index.js ├── server.js └── webpack.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── aliases.js ├── index.js ├── mixin.js ├── syncer.js ├── syncers │ ├── base.js │ ├── collection.js │ └── item.js └── utils.js └── test ├── aliases.test.js ├── collection.basic.test.js ├── collection.pagination.test.js ├── collection.query.test.js ├── core.test.js ├── feathers.core.test.js ├── helpers ├── before │ ├── feathers-and-vue-hookup.js │ ├── feathers-hookup.js │ └── vue-hookup.js ├── feathers-server.js ├── feathers-socket.js ├── global-require.js ├── mock-socket.js └── util.js ├── integration.test.js ├── item.test.js ├── tooling.test.js └── utils.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "targets": { 7 | "node": true 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "add-module-exports", 14 | "transform-runtime" 15 | ], 16 | "env": { 17 | "test": { 18 | "plugins": [ 19 | "istanbul" 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | indent_size = 4 9 | indent_style = tab 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [package.json] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | 20 | [*.yml] 21 | indent_style = space 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Dependency directory 7 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 8 | node_modules 9 | 10 | # Coverage 11 | .nyc_output 12 | coverage 13 | 14 | # Built example 15 | example/*.build.js 16 | example/*.build.js.map 17 | stats.json 18 | 19 | # Dist version (made in pre-publish and sent directly to npm) 20 | dist/ 21 | 22 | # Editors/IDEs 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | sudo: false 5 | 6 | script: npm run ci:test 7 | 8 | after_success: 9 | - npm install coveralls ocular.js 10 | - $(npm bin)/nyc report --reporter=text-lcov | $(npm bin)/coveralls 11 | - $(npm bin)/nyc report --reporter=clover 12 | - $(npm bin)/ocular coverage/clover.xml 13 | 14 | cache: 15 | directories: 16 | - node_modules 17 | 18 | before_cache: 19 | - rm -rf node_modules/.cache 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 t2t2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-syncers-feathers 2 | 3 | > Synchronises feathers services with vue objects, updated in real-time 4 | 5 | [![Build Status](https://travis-ci.org/t2t2/vue-syncers-feathers.svg?branch=master)](https://travis-ci.org/t2t2/vue-syncers-feathers) 6 | [![Coverage Status](https://coveralls.io/repos/github/t2t2/vue-syncers-feathers/badge.svg?branch=master)](https://coveralls.io/github/t2t2/vue-syncers-feathers?branch=master) 7 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/t2t2/vue-syncers-feathers/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/t2t2/vue-syncers-feathers/?branch=master) 8 | 9 | [Changelog on GitHub releases](https://github.com/t2t2/vue-syncers-feathers/releases) 10 | 11 | ## Setup 12 | 13 | `npm install vue-syncers-feathers feathers-commons feathers-query-filters --save` 14 | 15 | ### Webpack/Browserify 16 | 17 | ```js 18 | // Set up feathers client 19 | // You can do this whatever way you prefer, eg. feathers-client 20 | import feathers from 'feathers/client' 21 | import feathersIO from 'feathers-socketio/client' 22 | import io from 'socket.io-client' 23 | const socket = io() 24 | const client = feathers().configure(feathersIO(socket)) 25 | 26 | // Set up vue & VueSyncersFeathers 27 | import Vue from 'vue' 28 | import VueSyncersFeathers from 'vue-syncers-feathers' 29 | 30 | Vue.use(VueSyncersFeathers, { 31 | feathers: client 32 | }) 33 | ``` 34 | 35 | ### Configuration 36 | 37 | * `aliases` - [Enable shorter syntax](#aliases) 38 | * `feathers` **[REQUIRED]** - [feathers client](http://docs.feathersjs.com/clients/readme.html) instance 39 | * `idField` - Default idField value (see [syncer settings](#general-syncer-settings)), defaults to `id` 40 | 41 | **ADVANCED** - Most of the time you do not need these 42 | 43 | * `driver` - Swapping out syncers with your own custom version. See `src/syncer.js` 44 | * `filter` - Function that parses the query for special filters. 45 | Check [feathers-query-filters](https://github.com/feathersjs/feathers-query-filters) for syntax. 46 | * `matcher` - Function that creates a matcher used to check if an item matches the query. 47 | By default [feathers-commons](https://github.com/feathersjs/feathers-commons) matcher is used. 48 | 49 | ## Usage 50 | 51 | ```vue 52 | 59 | 84 | ``` 85 | 86 | ### `sync` option object 87 | 88 | key: path where the object will be (`vm.key`) 89 | value: `string|object` Service to use, or options object: 90 | 91 | #### General syncer settings 92 | 93 | * `service`: `string` service to use (same as `feathers.service(value)`) 94 | * `idField`: `string` ID field (defaults to `id`) 95 | * `loaded`: `function()` that will be executed when the syncer is loaded. This can happen multiple times (if data is loaded again). 96 | * `errored`: `function(error)` that will be executed when the syncer loads an error. This can happen multiple times (if data is loaded again). 97 | 98 | To use loaded and error event handler on all syncers check [instance events](#instance-events) 99 | 100 | #### Collection options (default) 101 | 102 | * `query`: `function()|string` query to send to the server 103 | 104 | `vm.key` will be object where keys are object IDs (empty if none matches/all deleted) 105 | 106 | #### Single item options (if id is set) 107 | 108 | * `id`: `function()|string` function that returns the item ID to fetch. 109 | 110 | `vm.key` will be the object which ID matches (or null on error/deletion) 111 | 112 | ### Reactivity 113 | 114 | Both id and query are sent to [vm.$watch](http://vuejs.org/api/#vm-watch) to get and observe the value. If the value 115 | is changed (eg. `id: () => { return this.shownUserId }` and `this.shownUserId = 3` later), the new object is requested 116 | from the server. If new the value is `null`, the request won't be sent and current value is set to empty object 117 | (collection mode) or null (single item mode) 118 | 119 | ```js 120 | export default { 121 | data() { 122 | return { 123 | userId: 1 124 | } 125 | }, 126 | sync: { 127 | user: { 128 | service: 'users', 129 | id() { 130 | return this.userId 131 | } 132 | } 133 | } 134 | } 135 | 136 | instance.userId = 2 // loads user id = 2 137 | ``` 138 | 139 | ### Instance methods 140 | 141 | * `vm.$refreshSyncers([path])` - Refresh syncers on this instance. Path can be key or array of keys to refresh. 142 | If not set, all syncers are updated. Note that this does not need to be called after creating/updating/removing items 143 | unless [events have been disabled](https://docs.feathersjs.com/real-time/filtering.html). 144 | 145 | ### Instance properties 146 | 147 | * `vm.$feathers` - Feathers client 148 | * `vm.$loadingSyncers` (reactive) - true if any syncers are in loading state 149 | 150 | ### Instance events 151 | 152 | * `syncer-loaded(key)` - Emitted when one of the syncers finishes loading it's data 153 | * `syncer-error(key, error)` - Emitted when one of the syncers results in error while loading it's data 154 | 155 | ## Aliases 156 | 157 | For cleaner code you can enable the following aliases by setting `aliases` option true in the `Vue.use` call. 158 | Note that these aren't enabled by default to avoid conflicts with any other vue plugins you might be using. 159 | 160 | Alias | Is same as | Key for individual enabling 161 | ---|---|--- 162 | `vm.$loading` | `vm.$loadingSyncers` | `loading` 163 | `vm.$refresh()` | `vm.$refreshSyncers` | `refresh` 164 | `vm.$service(name)` | `vm.$feathers.service(name)` | `service` 165 | 166 | ```js 167 | // Enable all 168 | Vue.use(VueSyncersFeathers, { 169 | aliases: true, 170 | feathers: client 171 | }) 172 | // Enable some 173 | Vue.use(VueSyncersFeathers, { 174 | aliases: { 175 | loading: true, 176 | service: true 177 | }, 178 | feathers: client 179 | }) 180 | ``` 181 | 182 | Example component with aliases: 183 | 184 | ```vue 185 | 191 | 205 | 206 | ``` 207 | 208 | ## FAQ 209 | 210 | * Can I use computed variables in query/id 211 | Yes 212 | * Can I use results in computed variables 213 | Yes 214 | * Vue-router/other plugin's objec-- 215 | Untested, but probably anything that integrates with vue (and properly defines reactivity) works 216 | 217 | ## Compatibility warnings: 218 | 219 | * `feathers-socket-commons 2.2.0 - 2.3.0`: Broken event listener removal 220 | -------------------------------------------------------------------------------- /example/app.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 29 | 30 | 45 | -------------------------------------------------------------------------------- /example/components/countries.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 77 | 78 | 111 | -------------------------------------------------------------------------------- /example/components/single.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 68 | -------------------------------------------------------------------------------- /example/components/todos.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 130 | 131 | 161 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vue-syncers-feathers 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | // Feathers client 2 | import feathers from 'feathers/client' 3 | import feathersIO from 'feathers-socketio/client' 4 | import io from 'socket.io-client' 5 | import Vue from 'vue' 6 | import VueSyncersFeathers from '../src' 7 | 8 | import App from './app.vue' 9 | 10 | const socket = io() 11 | const client = feathers() 12 | client.configure(feathersIO(socket)) 13 | 14 | // Patch in {$like: 'var'} ability to special filters 15 | require('feathers-commons/lib/utils').specialFilters.$like = function (key, value) { 16 | value = value.toString().toLowerCase() 17 | return function (current) { 18 | return current[key].toString().toLowerCase().indexOf(value) !== -1 19 | } 20 | } 21 | 22 | // Install vue-syncers-feathers 23 | Vue.use(VueSyncersFeathers, { 24 | feathers: client 25 | }) 26 | 27 | // Create instance 28 | const app = new Vue(App) 29 | global.app = app 30 | app.$mount('#app') 31 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | const feathers = require('feathers') 2 | const rest = require('feathers-rest') 3 | const bodyParser = require('body-parser') 4 | const socketio = require('feathers-socketio') 5 | const memory = require('feathers-memory') 6 | 7 | // Patch in {like: 'var'} ability to feathers-memory query 8 | require('feathers-commons/lib/utils').specialFilters.$like = function (key, value) { 9 | value = value.toString().toLowerCase() 10 | return function (current) { 11 | return current[key].toString().toLowerCase().indexOf(value) !== -1 12 | } 13 | } 14 | 15 | const app = feathers() 16 | 17 | app.configure(rest()) 18 | app.configure(socketio()) 19 | 20 | app.use(bodyParser.json()) 21 | app.use(bodyParser.urlencoded({extended: true})) 22 | 23 | app.service('todos', memory({ 24 | startId: 2, 25 | store: { 26 | 1: { 27 | id: 1, 28 | title: 'Test Todo', 29 | completed: false 30 | } 31 | } 32 | })) 33 | 34 | app.service('countries', memory({ 35 | /* Paginate: { 36 | default: 25, 37 | max: 50, 38 | }, */ 39 | })) 40 | 41 | // Webpack server 42 | const webpack = require('webpack') 43 | const webpackConfig = require('./webpack.config') 44 | 45 | const compiler = webpack(webpackConfig) 46 | 47 | app.use(require('webpack-dev-middleware')(compiler, { 48 | publicPath: webpackConfig.output.publicPath, 49 | noInfo: true, 50 | stats: { 51 | colors: true 52 | } 53 | })) 54 | app.use(require('webpack-hot-middleware')(compiler)) 55 | 56 | // Static files 57 | app.use('/', feathers.static(__dirname)) 58 | 59 | // Seed with data 60 | app.service('countries').create(require('country-data').countries.all) 61 | 62 | app.listen(8030, () => { 63 | console.log('Serving examples on http://localhost:8030') 64 | }) 65 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | 4 | module.exports = { 5 | // Context: path.resolve(__dirname, './'), 6 | entry: { 7 | example: ['webpack-hot-middleware/client?reload=true', './example/index.js'] 8 | }, 9 | output: { 10 | path: path.normalize(path.resolve(__dirname, './')), 11 | filename: '[name].build.js', 12 | publicPath: '/' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.vue$/, 18 | use: 'vue-loader' 19 | }, 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | use: 'babel-loader' 24 | } 25 | ] 26 | }, 27 | plugins: [ 28 | new webpack.HotModuleReplacementPlugin(), 29 | new webpack.DefinePlugin({ 30 | 'process.env': { 31 | NODE_ENV: '"development"' 32 | } 33 | }) 34 | ], 35 | devtool: 'source-map' 36 | } 37 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=759670 3 | // for the documentation about the jsconfig.json format 4 | "compilerOptions": { 5 | "target": "es6", 6 | "allowSyntheticDefaultImports": true, 7 | "experimentalDecorators": true 8 | }, 9 | "exclude": [ 10 | "node_modules", 11 | "dist" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-syncers-feathers", 3 | "version": "0.4.1", 4 | "description": "Synchronises feathers services with vue objects, updated in real time", 5 | "license": "MIT", 6 | "main": "dist/vue-syncers-feathers.common.js", 7 | "jsnext:main": "src/index.js", 8 | "files": [ 9 | "dist", 10 | "src" 11 | ], 12 | "scripts": { 13 | "build": "npm-run-all clean:dist build:*", 14 | "build:commonjs": "rollup -c", 15 | "build:esm": "rollup -c", 16 | "ci:test": "npm-run-all lint coverage", 17 | "clean": "npm-run-all clean:*", 18 | "clean:dist": "rimraf dist/*.*", 19 | "clean:coverage": "rimraf coverage/**/*", 20 | "coverage": "cross-env NODE_ENV=test nyc npm run unit", 21 | "coverage-html": "npm-run-all clean:coverage coverage && nyc report --reporter=html", 22 | "lint": "xo", 23 | "prepublish": "npm run build", 24 | "serve-example": "node example/server.js", 25 | "test": "npm-run-all lint unit", 26 | "unit": "ava" 27 | }, 28 | "keywords": [ 29 | "vue", 30 | "vuejs", 31 | "feathers", 32 | "feathersjs" 33 | ], 34 | "author": "t2t2 ", 35 | "repository": "t2t2/vue-syncers-feathers", 36 | "peerDependencies": { 37 | "feathers-commons": "^0.8.7", 38 | "feathers-query-filters": "^2.1.1" 39 | }, 40 | "devDependencies": { 41 | "ava": "^0.21.0", 42 | "babel-core": "^6.25.0", 43 | "babel-loader": "^7.0.0", 44 | "babel-plugin-add-module-exports": "^0.2.1", 45 | "babel-plugin-external-helpers": "^6.18.0", 46 | "babel-plugin-istanbul": "^4.1.4", 47 | "babel-plugin-transform-runtime": "^6.15.0", 48 | "babel-preset-env": "^1.3.2", 49 | "babel-preset-latest": "^6.24.1", 50 | "babel-register": "^6.24.1", 51 | "babel-runtime": "^6.20.0", 52 | "body-parser": "^1.17.2", 53 | "country-data": "^0.0.31", 54 | "cross-env": "^5.0.1", 55 | "css-loader": "^0.28.0", 56 | "feathers": "^2.1.4", 57 | "feathers-commons": "^0.8.7", 58 | "feathers-memory": "^1.0.1", 59 | "feathers-query-filters": "^2.1.1", 60 | "feathers-rest": "^1.8.0", 61 | "feathers-socket-commons": "^2.3.1", 62 | "feathers-socketio": "^2.0.0", 63 | "json-loader": "^0.5.4", 64 | "lodash": "^4.17.4", 65 | "mock-socket": "^6.1.0", 66 | "npm-run-all": "^4.0.0", 67 | "nyc": "^11.0.3", 68 | "rimraf": "^2.5.4", 69 | "rollup": "^0.47.0", 70 | "rollup-plugin-babel": "^3.0.0", 71 | "socket.io-client": "^2.0.3", 72 | "uberproto": "^1.2.0", 73 | "vue": "^2.3.4", 74 | "vue-hot-reload-api": "^2.1.0", 75 | "vue-html-loader": "^1.2.3", 76 | "vue-loader": "^13.0.0", 77 | "vue-style-loader": "^3.0.0", 78 | "vue-template-compiler": "^2.3.4", 79 | "webpack": "^3.1.0", 80 | "webpack-dev-middleware": "^1.11.0", 81 | "webpack-hot-middleware": "^2.18.2", 82 | "xo": "^0.18.2" 83 | }, 84 | "ava": { 85 | "fail-fast": true, 86 | "files": "test/**/*.test.js", 87 | "require": [ 88 | "./test/helpers/global-require" 89 | ] 90 | }, 91 | "nyc": { 92 | "require": [ 93 | "babel-register" 94 | ], 95 | "sourceMap": false, 96 | "instrument": false 97 | }, 98 | "xo": { 99 | "envs": [ 100 | "node", 101 | "browser" 102 | ], 103 | "ignores": [ 104 | "dist/*.js", 105 | "example/*.build.js" 106 | ], 107 | "semicolon": false 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import babel from 'rollup-plugin-babel' 3 | 4 | /* 5 | If(!process.env.PROD_BUILD_MODE) { 6 | process.env.PROD_BUILD_MODE = 'commonjs' 7 | } 8 | */ 9 | 10 | const config = { 11 | entry: path.join(__dirname, '/src/index.js'), 12 | external: [ 13 | 'feathers-commons/lib/utils', 14 | 'feathers-query-filters' 15 | ], 16 | plugins: [ 17 | babel({ 18 | presets: [ 19 | ['env', { 20 | targets: { 21 | browsers: '> 1%, Last 2 versions, IE 9' // Based on vue's requirements 22 | }, 23 | modules: false, 24 | loose: true 25 | }] 26 | ], 27 | plugins: [ 28 | 'external-helpers' 29 | ], 30 | babelrc: false 31 | }) 32 | ] 33 | } 34 | 35 | if (process.env.npm_lifecycle_event === 'build:commonjs') { 36 | // Common.js build 37 | config.format = 'cjs' 38 | config.dest = path.join(__dirname, '/dist/vue-syncers-feathers.common.js') 39 | } else if (process.env.npm_lifecycle_event === 'build:esm') { 40 | // Common.js build 41 | config.format = 'es' 42 | config.dest = path.join(__dirname, '/dist/vue-syncers-feathers.esm.js') 43 | } 44 | 45 | export default config 46 | -------------------------------------------------------------------------------- /src/aliases.js: -------------------------------------------------------------------------------- 1 | import {each} from './utils' 2 | 3 | const variables = { 4 | loading() { 5 | return this.$loadingSyncers 6 | } 7 | } 8 | 9 | const methods = { 10 | refresh(...args) { 11 | return this.$refreshSyncers(...args) 12 | }, 13 | service(...args) { 14 | return this.$feathers.service(...args) 15 | } 16 | } 17 | 18 | /** 19 | * Create mixin by passed in options 20 | * 21 | * @param {Boolean|Object} options 22 | */ 23 | export default function aliasesMixinMaker(options) { 24 | let isEnabled 25 | if (typeof options === 'boolean') { 26 | isEnabled = () => options 27 | } else { 28 | isEnabled = key => { 29 | return key in options && options[key] 30 | } 31 | } 32 | 33 | const mixin = { 34 | computed: {}, // Variables 35 | methods: {} 36 | } 37 | 38 | each(variables, (getter, key) => { 39 | if (isEnabled(key)) { 40 | mixin.computed[`$${key}`] = getter 41 | } 42 | }) 43 | 44 | each(methods, (caller, key) => { 45 | if (isEnabled(key)) { 46 | mixin.methods[`$${key}`] = caller 47 | } 48 | }) 49 | 50 | return mixin 51 | } 52 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import filter from 'feathers-query-filters' 2 | import {matcher} from 'feathers-commons/lib/utils' 3 | 4 | import aliasesMixinMaker from './aliases' 5 | import Syncer from './syncer' 6 | import syncerMixin from './mixin' 7 | 8 | const defaults = { 9 | aliases: false, 10 | driver: Syncer, 11 | filter, 12 | idField: 'id', 13 | matcher 14 | } 15 | 16 | export default { 17 | /** 18 | * Install to vue 19 | * 20 | * @function 21 | * @param {Vue} Vue - Vue 22 | * @param {Object} options - Options 23 | * @param {Function} [options.aliases] - Aliases to enable 24 | * @param {Function} [options.driver] - Custom driver to use 25 | * @param {Function} [options.filter] - Query filter parser 26 | * @param {Object} [options.feathers] - Feathers client 27 | * @param {string} [options.idField] - Default ID field 28 | * @param {Function} [options.matcher] - Matcher creator 29 | */ 30 | install(Vue, options = {}) { 31 | const extend = Vue.util.extend 32 | // Vue 2.0 has util.toObject, but 1.0 doesn't 33 | options = extend(extend({}, defaults), options) 34 | 35 | if (!('feathers' in options)) { 36 | throw new Error('No feathers instance set in options') 37 | } 38 | 39 | Vue.$syncer = options 40 | Vue.prototype.$feathers = options.feathers 41 | 42 | Vue.mixin(syncerMixin(Vue)) 43 | // Mixin handling 44 | Vue.config.optionMergeStrategies.sync = Vue.config.optionMergeStrategies.props 45 | 46 | if (options.aliases) { 47 | Vue.mixin(aliasesMixinMaker(options.aliases)) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/mixin.js: -------------------------------------------------------------------------------- 1 | import {each, noop, some} from './utils' 2 | 3 | /** 4 | * Install mixin onto the Vue instance 5 | * 6 | * @param {Vue} Vue - Vue 7 | */ 8 | export default function (Vue) { 9 | const VueVersion = Number(Vue.version && Vue.version.split('.')[0]) 10 | const initHook = VueVersion && VueVersion > 1 ? 'beforeCreate' : 'init' 11 | 12 | return { 13 | [initHook]: beforeCreate(Vue), 14 | created: created(), 15 | beforeDestroy: beforeDestroy(), 16 | computed: { 17 | $loadingSyncers: loadingStateGetter 18 | }, 19 | methods: { 20 | $refreshSyncers: refreshSyncers 21 | } 22 | } 23 | } 24 | 25 | /* 26 | * Before creation hook 27 | * 28 | * @param {Vue} Vue - Vue 29 | */ 30 | function beforeCreate(Vue) { 31 | return function () { 32 | this._syncers = {} 33 | 34 | const SyncCreator = Vue.$syncer.driver 35 | const synced = this.$options.sync 36 | if (synced) { 37 | // Set up each syncer 38 | each(synced, (settings, key) => { 39 | this._syncers[key] = new SyncCreator(Vue, this, key, settings) 40 | 41 | Object.defineProperty(this, key, { 42 | get: () => { 43 | return this._syncers[key] ? this._syncers[key].state : null 44 | }, 45 | set: noop, 46 | enumerable: true, 47 | configurable: true 48 | }) 49 | }) 50 | } 51 | } 52 | } 53 | 54 | /** 55 | * After creation hook 56 | */ 57 | function created() { 58 | return function () { 59 | // Start syncers 60 | each(this._syncers, syncer => { 61 | syncer.ready() 62 | }) 63 | } 64 | } 65 | 66 | /** 67 | * Before destruction hook 68 | */ 69 | function beforeDestroy() { 70 | return function () { 71 | each(this._syncers, (syncer, key) => { 72 | syncer.destroy() 73 | delete this._syncers[key] 74 | }) 75 | } 76 | } 77 | 78 | /** 79 | * Get loading state of the syncers 80 | * 81 | * @returns {boolean} 82 | */ 83 | function loadingStateGetter() { 84 | if (Object.keys(this._syncers).length > 0) { 85 | return some(this._syncers, syncer => { 86 | return syncer.loading 87 | }) 88 | } 89 | return false 90 | } 91 | 92 | /** 93 | * Refresh syncers state 94 | * 95 | * @param {string|string[]} [keys] - Syncers to refresh 96 | */ 97 | function refreshSyncers(keys) { 98 | if (typeof keys === 'string') { 99 | keys = [keys] 100 | } 101 | if (!keys) { 102 | keys = Object.keys(this._syncers) 103 | } 104 | return Promise.all(keys.map(key => { 105 | return this._syncers[key].refresh() 106 | })) 107 | } 108 | -------------------------------------------------------------------------------- /src/syncer.js: -------------------------------------------------------------------------------- 1 | import CollectionSyncer from './syncers/collection' 2 | import ItemSyncer from './syncers/item' 3 | 4 | /** 5 | * Chooses and returns the preferred syncer 6 | * 7 | * @param Vue 8 | * @param vm 9 | * @param path 10 | * @param settings 11 | * @returns {BaseFeathersSyncer} 12 | */ 13 | export default function syncerChooser(Vue, vm, path, settings) { 14 | if (typeof settings === 'string') { 15 | settings = { 16 | service: settings 17 | } 18 | } 19 | 20 | // Choose syncer to use 21 | if ('id' in settings) { 22 | return new ItemSyncer(Vue, vm, path, settings) 23 | } 24 | return new CollectionSyncer(Vue, vm, path, settings) 25 | } 26 | -------------------------------------------------------------------------------- /src/syncers/base.js: -------------------------------------------------------------------------------- 1 | import {each, warn} from '../utils' 2 | 3 | export default class BaseFeathersSyncer { 4 | 5 | /** 6 | * Create a syncer for feathers 7 | * 8 | * @param Vue 9 | * @param vm 10 | * @param path 11 | * @param settings 12 | */ 13 | constructor(Vue, vm, path, settings) { 14 | this.Vue = Vue 15 | this.vm = vm 16 | this.path = path 17 | this.settings = settings 18 | 19 | this.filters = {} 20 | this.unwatchers = {} 21 | this.events = { 22 | loaded: settings.loaded, 23 | error: settings.errored 24 | } 25 | 26 | Vue.util.defineReactive(this, 'state', this._initialState()) 27 | Vue.util.defineReactive(this, 'loading', true) 28 | 29 | this._id = 'idField' in settings ? settings.idField : Vue.$syncer.idField 30 | 31 | const client = Vue.$syncer.feathers 32 | this.service = client.service(this.settings.service) 33 | } 34 | 35 | /** 36 | * Cleanup after oneself 37 | */ 38 | destroy() { 39 | each(this.unwatchers, unwatcher => { 40 | unwatcher() 41 | }) 42 | 43 | this.state = this._initialState() 44 | this.vm = null 45 | this.settings = null 46 | this.Vue = null 47 | this.service = null 48 | } 49 | 50 | /** 51 | * Hook into feathers and set up value observers 52 | * 53 | * @returns {*} 54 | */ 55 | ready() { 56 | this._listenForServiceEvent('created', this.onItemCreated.bind(this)) 57 | this._listenForServiceEvent('updated', this.onItemUpdated.bind(this)) 58 | this._listenForServiceEvent('patched', this.onItemUpdated.bind(this)) 59 | this._listenForServiceEvent('removed', this.onItemRemoved.bind(this)) 60 | 61 | return this._bindComputedValues() 62 | } 63 | 64 | /** 65 | * Refresh syncer's value 66 | */ 67 | refresh() { 68 | return this._loadNewState() 69 | } 70 | 71 | /** 72 | * Handle errors loading the state 73 | * 74 | * @param error 75 | * @private 76 | */ 77 | _handleStateLoadingError(error) { 78 | this.loading = false 79 | this._fireEvent('error', error) 80 | } 81 | 82 | /** 83 | * Register service listener and unlistener 84 | * 85 | * @param event 86 | * @param callback 87 | * @private 88 | */ 89 | _listenForServiceEvent(event, callback) { 90 | /* istanbul ignore next */ 91 | if (process.env.NODE_ENV !== 'production') { 92 | const origCallback = callback 93 | callback = (...args) => { 94 | if (this.Vue === null) { 95 | warn('Removed event listener is being called. Please update feathers-socket-commons package.') 96 | return 97 | } 98 | 99 | origCallback(...args) 100 | } 101 | } 102 | 103 | this.service.on(event, callback) 104 | this.unwatchers['service-' + event] = () => { 105 | this.service.off(event, callback) 106 | } 107 | } 108 | 109 | /** 110 | * Wrapper for loading current state 111 | * 112 | * @returns {Promise.} 113 | * @private 114 | */ 115 | _loadNewState() { 116 | this.loading = true 117 | return this._loadState() 118 | } 119 | 120 | /** 121 | * Mark as everything's now loaded 122 | * 123 | * @private 124 | */ 125 | _newStateLoaded() { 126 | this.loading = false 127 | this._fireEvent('loaded') 128 | } 129 | 130 | /** 131 | * Fire event on both listeners in settings and instance 132 | * 133 | * @private 134 | */ 135 | _fireEvent(event, ...args) { 136 | if (event in this.events && this.events[event]) { 137 | this.events[event].apply(this.vm, args) 138 | } 139 | this.vm.$emit(`syncer-${event}`, this.path, ...args) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/syncers/collection.js: -------------------------------------------------------------------------------- 1 | import {looseEqual, pick} from '../utils' 2 | import BaseSyncer from './base' 3 | 4 | /** 5 | * Collection syncer used for multiple items 6 | */ 7 | export default class CollectionSyncer extends BaseSyncer { 8 | 9 | /** 10 | * Create a syncer for feathers 11 | * 12 | * @param Vue 13 | * @param vm 14 | * @param path 15 | * @param settings 16 | */ 17 | constructor(Vue, vm, path, settings) { 18 | super(Vue, vm, path, settings) 19 | 20 | this._matcher = () => true // For without query 21 | this._createMatcher = Vue.$syncer.matcher 22 | this._filterParser = Vue.$syncer.filter 23 | } 24 | 25 | /** 26 | * Handle new item creations from feathers 27 | * 28 | * @param item 29 | */ 30 | onItemCreated(item) { 31 | if (this._itemMatches(item)) { 32 | item = this._transformPerQuery(item) 33 | this._set(item[this._id], item) 34 | } 35 | } 36 | 37 | /** 38 | * Handle item updates from feathers 39 | * 40 | * @param item 41 | */ 42 | onItemUpdated(item) { 43 | if (this._itemMatches(item)) { 44 | item = this._transformPerQuery(item) 45 | this._set(item[this._id], item) 46 | } else if (item[this._id] in this.state) { 47 | this._remove(item[this._id]) 48 | } 49 | } 50 | 51 | /** 52 | * Handle item removals from feathers 53 | * 54 | * @param item 55 | */ 56 | onItemRemoved(item) { 57 | if (item[this._id] in this.state) { 58 | this._remove(item[this._id]) 59 | } 60 | } 61 | 62 | /** 63 | * Bind watchers for computed values 64 | * 65 | * @private 66 | */ 67 | _bindComputedValues() { 68 | if ('query' in this.settings) { 69 | this.filters.query = null 70 | 71 | // When new value is found 72 | const callback = function (newVal) { 73 | // Avoid re-querying if it's the same 74 | if (looseEqual(this.filters.query, newVal)) { 75 | this.filters.query = newVal 76 | return 77 | } 78 | 79 | this.filters.query = newVal 80 | if (newVal === null) { 81 | this.filters.queryParsed = null 82 | } else { 83 | this.filters.queryParsed = this._filterParser(newVal) 84 | } 85 | 86 | // Clear state (if query is now null it makes sure everything's reset) 87 | this.state = this._initialState() 88 | this._matcher = () => false 89 | 90 | // Default return nothing 91 | let returning = false 92 | if (this.filters.query !== null) { 93 | this._matcher = this._createMatcher(this.filters.query) 94 | returning = this._loadNewState() 95 | } 96 | 97 | if ('hook' in callback) { 98 | callback.hook(returning) 99 | delete callback.hook 100 | } 101 | } 102 | 103 | return new Promise(resolve => { 104 | callback.hook = resolve 105 | 106 | this.unwatchers.query = this.vm.$watch(this.settings.query, callback.bind(this), {immediate: true}) 107 | }) 108 | } 109 | 110 | return this._loadNewState() 111 | } 112 | 113 | /** 114 | * Initial data for item syncer 115 | * 116 | * @returns {*} 117 | * @private 118 | */ 119 | _initialState() { 120 | return {} 121 | } 122 | 123 | /** 124 | * Checks if item matches what's in collection 125 | * 126 | * @param item 127 | * @returns {boolean} 128 | * @private 129 | */ 130 | _itemMatches(item) { 131 | return this._matcher(item) 132 | } 133 | 134 | /** 135 | * Load the requested state 136 | * 137 | * @returns {Promise.} 138 | * @private 139 | */ 140 | _loadState() { 141 | const params = {} 142 | 143 | if (this.filters.query) { 144 | params.query = this.filters.query 145 | } 146 | 147 | return this.service.find(params).then(items => { 148 | if (this.vm === null) { 149 | // Destroy has been called during loading 150 | return items 151 | } 152 | 153 | this.state = this._initialState() 154 | 155 | // If the service is paginated 156 | if (Array.isArray(items) === false && typeof items.data !== 'undefined') { 157 | items = items.data 158 | } 159 | 160 | items.forEach(item => { 161 | this._set(item[this._id], item) 162 | }) 163 | this._newStateLoaded() 164 | 165 | return items 166 | }).catch(this._handleStateLoadingError.bind(this)) 167 | } 168 | 169 | /** 170 | * Set current item 171 | * 172 | * @param key 173 | * @param item 174 | * @private 175 | */ 176 | _set(key, item) { 177 | this.Vue.set(this.state, key, item) 178 | } 179 | 180 | /** 181 | * Remove current item 182 | * 183 | * @private 184 | */ 185 | _remove(key) { 186 | this.Vue.delete(this.state, key) 187 | } 188 | 189 | /** 190 | * Transform item using current filter's rules 191 | * 192 | * @param item 193 | * @private 194 | */ 195 | _transformPerQuery(item) { 196 | if (this.filters.queryParsed) { 197 | const filters = this.filters.queryParsed.filters 198 | if (filters.$select) { 199 | item = pick(item, ...filters.$select) 200 | } 201 | } 202 | return item 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/syncers/item.js: -------------------------------------------------------------------------------- 1 | import {warn, isNumericIDLike} from '../utils' 2 | import BaseSyncer from './base' 3 | 4 | /** 5 | * Item syncer used for when there's no constraints 6 | */ 7 | export default class ItemSyncer extends BaseSyncer { 8 | 9 | /** 10 | * Handle new item creations from feathers 11 | * 12 | * @param item 13 | */ 14 | onItemCreated(item) { 15 | if (item[this._id] === this.filters.id) { 16 | this._set(item) 17 | } 18 | } 19 | 20 | /** 21 | * Handle item updates from feathers 22 | * 23 | * @param item 24 | */ 25 | onItemUpdated(item) { 26 | if (item[this._id] === this.filters.id) { 27 | this._set(item) 28 | } 29 | } 30 | 31 | /** 32 | * Handle item removals from feathers 33 | * 34 | * @param item 35 | */ 36 | onItemRemoved(item) { 37 | if (item[this._id] === this.filters.id) { 38 | this._remove() 39 | } 40 | } 41 | 42 | /** 43 | * Bind watchers for computed values 44 | * 45 | * @private 46 | */ 47 | _bindComputedValues() { 48 | this.filters.id = null 49 | 50 | // When new value is found 51 | function callback(newVal) { 52 | this.filters.id = newVal 53 | 54 | // Warn about string id's that seem like they shooooouldn't 55 | /* istanbul ignore next */ 56 | if (process.env.NODE_ENV !== 'production' && isNumericIDLike(newVal)) { 57 | warn('String ID that looks like a number given', this.path, newVal) 58 | } 59 | 60 | // Clear state (if now null it just makes sure) 61 | this.state = this._initialState() 62 | 63 | // Default return nothing 64 | let returning = false 65 | if (this.filters.id !== null) { 66 | returning = this._loadNewState() 67 | } 68 | 69 | if ('hook' in callback) { 70 | callback.hook(returning) 71 | delete callback.hook 72 | } 73 | } 74 | 75 | return new Promise(resolve => { 76 | callback.hook = resolve 77 | 78 | this.unwatchers.id = this.vm.$watch(this.settings.id, callback.bind(this), {immediate: true}) 79 | }) 80 | } 81 | 82 | /** 83 | * Initial data for item syncer 84 | * 85 | * @returns {*} 86 | * @private 87 | */ 88 | _initialState() { 89 | return null 90 | } 91 | 92 | /** 93 | * Load the requested state 94 | * 95 | * @returns {Promise.} 96 | * @private 97 | */ 98 | _loadState() { 99 | return this.service.get(this.filters.id).then(item => { 100 | if (this.vm === null) { 101 | // Destroy has been called during loading 102 | return item 103 | } 104 | 105 | this._set(item) 106 | this._newStateLoaded() 107 | 108 | return item 109 | }).catch(this._handleStateLoadingError.bind(this)) 110 | } 111 | 112 | /** 113 | * Set current item 114 | * 115 | * @param item 116 | * @private 117 | */ 118 | _set(item) { 119 | this.Vue.set(this, 'state', item) 120 | } 121 | 122 | /** 123 | * Remove current item 124 | * 125 | * @private 126 | */ 127 | _remove() { 128 | this.state = this._initialState() 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import * as feathersUtil from 'feathers-commons/lib/utils' 2 | 3 | export const each = feathersUtil.each 4 | export const some = feathersUtil._.some 5 | 6 | /** 7 | * Empty function 8 | */ 9 | export function noop() { 10 | } 11 | 12 | /** 13 | * Log debug in user's console 14 | * 15 | * @param args 16 | */ 17 | export function warn(...args) { 18 | /* istanbul ignore next */ 19 | if (console || window.console) { 20 | console.warn('[vue-syncers-feathers]', ...args) 21 | } 22 | } 23 | 24 | const numberRegex = /^\d+$/ 25 | 26 | /** 27 | * Test if a value seems like a number 28 | * 29 | * @param value 30 | * @returns {boolean} 31 | */ 32 | 33 | export function isNumericIDLike(value) { 34 | return (typeof value !== 'number' && numberRegex.test(value)) 35 | } 36 | 37 | /** 38 | * Return object with only selected keys 39 | * 40 | * @from https://github.com/feathersjs/feathers-memory 41 | * @param source 42 | * @param keys 43 | * @returns {object} 44 | */ 45 | export function pick(source, ...keys) { 46 | const result = {} 47 | for (const key of keys) { 48 | result[key] = source[key] 49 | } 50 | return result 51 | } 52 | 53 | /** 54 | * Check if object is JSONable 55 | * 56 | * @from https://github.com/vuejs/vue/blob/0b902e0c28f4f324ffb8efbc9db74127430f8a42/src/shared/util.js#L155 57 | * @param {*} obj 58 | * @returns {boolean} 59 | */ 60 | function isObject(obj) { 61 | return obj !== null && typeof obj === 'object' 62 | } 63 | 64 | /** 65 | * Loosely check if objects are equal 66 | * 67 | * @from https://github.com/vuejs/vue/blob/0b902e0c28f4f324ffb8efbc9db74127430f8a42/src/shared/util.js 68 | * @param {*} a 69 | * @param {*} b 70 | * @returns {boolean} 71 | */ 72 | export function looseEqual(a, b) { 73 | const isObjectA = isObject(a) 74 | const isObjectB = isObject(b) 75 | if (isObjectA && isObjectB) { 76 | try { 77 | return JSON.stringify(a) === JSON.stringify(b) 78 | } catch (err) { 79 | // Possible circular reference 80 | return a === b 81 | } 82 | } else if (!isObjectA && !isObjectB) { 83 | return String(a) === String(b) 84 | } else { 85 | return false 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /test/aliases.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import aliasesMixinMaker from '../src/aliases' 4 | 5 | import {addVueWithPlugin, vueCleanup} from './helpers/before/vue-hookup' 6 | 7 | function makeBaseDriver() { 8 | return class TestDriver { 9 | constructor(Vue) { 10 | Vue.util.defineReactive(this, 'state', {}) 11 | Vue.util.defineReactive(this, 'loading', true) 12 | } 13 | 14 | ready() { 15 | } 16 | 17 | destroy() { 18 | } 19 | 20 | refresh() { 21 | } 22 | } 23 | } 24 | 25 | test.afterEach(vueCleanup) 26 | 27 | test('All aliases', t => { 28 | let testing = null 29 | 30 | class TestSyncer extends makeBaseDriver() { 31 | refresh() { 32 | t.is(testing, 'refresh') 33 | } 34 | } 35 | 36 | addVueWithPlugin(t, { 37 | aliases: true, 38 | driver: TestSyncer, 39 | feathers: { 40 | service(service) { 41 | t.is(testing, 'service') 42 | t.is(service, 'manual-test') 43 | } 44 | } 45 | }) 46 | const {Vue} = t.context 47 | 48 | const instance = new Vue({ 49 | sync: { 50 | test: 'test' 51 | } 52 | }) 53 | 54 | testing = 'loading' 55 | t.is(instance.$loading, true) 56 | 57 | testing = 'refresh' 58 | instance.$refresh() 59 | 60 | testing = 'service' 61 | instance.$service('manual-test') 62 | }) 63 | 64 | test('Toggling aliases', t => { 65 | addVueWithPlugin(t, { 66 | driver: makeBaseDriver(), 67 | feathers: {} 68 | }) 69 | const {Vue} = t.context 70 | 71 | Vue.mixin(aliasesMixinMaker({ 72 | loading: false, 73 | refresh: true 74 | // Service: false is implied 75 | })) 76 | 77 | const instance = new Vue({ 78 | sync: { 79 | test: 'test' 80 | } 81 | }) 82 | 83 | t.is(typeof instance.$loading, 'undefined') 84 | t.is(typeof instance.$refresh, 'function') 85 | t.is(typeof instance.$service, 'undefined') 86 | }) 87 | -------------------------------------------------------------------------------- /test/collection.basic.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import CollectionSyncer from '../src/syncers/collection' 4 | 5 | import {addBasicService} from './helpers/before/feathers-hookup' 6 | import {addVueAndFeathers, vueAndFeathersCleanup} from './helpers/before/feathers-and-vue-hookup' 7 | 8 | test.beforeEach(addVueAndFeathers) 9 | test.beforeEach(addBasicService) 10 | test.beforeEach(t => { 11 | const Vue = t.context.Vue 12 | t.context.instance = new Vue({ 13 | data() { 14 | return { 15 | // To avoid vue-warn for setting paths on vm 16 | variables: {} 17 | } 18 | } 19 | }) 20 | 21 | t.context.createSyncer = function (settings) { 22 | return new CollectionSyncer(Vue, t.context.instance, 'test', settings) 23 | } 24 | }) 25 | 26 | test.afterEach(t => { 27 | if ('syncer' in t.context) { 28 | t.context.syncer.destroy() 29 | } 30 | }) 31 | test.afterEach(vueAndFeathersCleanup) 32 | 33 | test('Get basic collection', async t => { 34 | const {instance, createSyncer} = t.context 35 | 36 | instance.$on('syncer-error', (path, error) => { 37 | t.fail(error) 38 | }) 39 | 40 | const syncer = createSyncer({ 41 | service: 'test' 42 | }) 43 | t.context.syncer = syncer 44 | 45 | t.plan(4) 46 | 47 | // Loading by default 48 | t.truthy(syncer.loading) 49 | 50 | instance.$once('syncer-loaded', path => { 51 | // Correct path 52 | t.is(path, 'test') 53 | }) 54 | 55 | await syncer.ready() 56 | 57 | t.falsy(syncer.loading) 58 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}, 2: {id: 2, otherItem: true}}) 59 | }) 60 | 61 | test('New items are added to the instance', async t => { 62 | const {callService, createSyncer, instance} = t.context 63 | 64 | instance.$on('syncer-error', (path, error) => { 65 | t.fail(error) 66 | }) 67 | 68 | const syncer = createSyncer({ 69 | service: 'test' 70 | }) 71 | t.context.syncer = syncer 72 | 73 | await syncer.ready() 74 | await callService('create', {created: true}) 75 | 76 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}, 2: {id: 2, otherItem: true}, 3: {id: 3, created: true}}) 77 | }) 78 | 79 | test('Current items are updated on the instance', async t => { 80 | const {callService, createSyncer, instance} = t.context 81 | 82 | instance.$on('syncer-error', (path, error) => { 83 | t.fail(error) 84 | }) 85 | 86 | const syncer = createSyncer({ 87 | service: 'test' 88 | }) 89 | t.context.syncer = syncer 90 | 91 | await syncer.ready() 92 | await callService('update', 1, {id: 1, updated: true}) 93 | 94 | t.deepEqual(syncer.state, {1: {id: 1, updated: true}, 2: {id: 2, otherItem: true}}) 95 | }) 96 | 97 | test('Current items are patched on the instance', async t => { 98 | const {callService, createSyncer, instance} = t.context 99 | 100 | instance.$on('syncer-error', (path, error) => { 101 | t.fail(error) 102 | }) 103 | 104 | const syncer = createSyncer({ 105 | service: 'test' 106 | }) 107 | t.context.syncer = syncer 108 | 109 | await syncer.ready() 110 | await callService('patch', 1, {id: 1, updated: true}) 111 | 112 | t.deepEqual(syncer.state, {1: {id: 1, tested: true, updated: true}, 2: {id: 2, otherItem: true}}) 113 | }) 114 | 115 | test('Deleted things are removed on the instance', async t => { 116 | const {callService, instance, createSyncer} = t.context 117 | 118 | instance.$on('syncer-error', (path, error) => { 119 | t.fail(error) 120 | }) 121 | 122 | const syncer = createSyncer({ 123 | service: 'test' 124 | }) 125 | t.context.syncer = syncer 126 | 127 | await syncer.ready() 128 | await callService('remove', 1) 129 | 130 | t.deepEqual(syncer.state, {2: {id: 2, otherItem: true}}) 131 | }) 132 | 133 | test('Handle destruction while loading', async t => { 134 | const {createSyncer} = t.context 135 | 136 | const syncer = createSyncer({ 137 | service: 'test' 138 | }) 139 | 140 | const synced = syncer.ready() 141 | syncer.destroy() 142 | await synced 143 | t.pass() 144 | }) 145 | 146 | -------------------------------------------------------------------------------- /test/collection.pagination.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import CollectionSyncer from '../src/syncers/collection' 4 | 5 | import {addPaginatedService} from './helpers/before/feathers-hookup' 6 | import {addVueAndFeathers, vueAndFeathersCleanup} from './helpers/before/feathers-and-vue-hookup' 7 | 8 | test.beforeEach(addVueAndFeathers) 9 | test.beforeEach(addPaginatedService) 10 | test.beforeEach(t => { 11 | const Vue = t.context.Vue 12 | t.context.instance = new Vue({ 13 | data() { 14 | return { 15 | // To avoid vue-warn for setting paths on vm 16 | variables: {} 17 | } 18 | } 19 | }) 20 | 21 | t.context.createSyncer = function (settings) { 22 | return new CollectionSyncer(Vue, t.context.instance, 'test', settings) 23 | } 24 | }) 25 | 26 | test.afterEach(t => { 27 | if ('syncer' in t.context) { 28 | t.context.syncer.destroy() 29 | } 30 | }) 31 | test.afterEach(vueAndFeathersCleanup) 32 | 33 | test('Basic handling of pagination', async t => { 34 | const {instance, createSyncer} = t.context 35 | 36 | instance.$on('syncer-error', (path, error) => { 37 | t.fail(error) 38 | }) 39 | 40 | const syncer = createSyncer({ 41 | service: 'paginated', 42 | query() { 43 | return { 44 | $limit: 3 45 | } 46 | } 47 | }) 48 | t.context.syncer = syncer 49 | 50 | t.plan(4) 51 | 52 | // Loading by default 53 | t.truthy(syncer.loading) 54 | 55 | instance.$once('syncer-loaded', path => { 56 | // Correct path 57 | t.is(path, 'test') 58 | }) 59 | 60 | await syncer.ready() 61 | 62 | t.falsy(syncer.loading) 63 | t.deepEqual(syncer.state, {1: {id: 1, item: 'first'}, 2: {id: 2, item: 'second'}, 3: {id: 3, item: 'third'}}) 64 | }) 65 | -------------------------------------------------------------------------------- /test/collection.query.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import CollectionSyncer from '../src/syncers/collection' 4 | 5 | import {addBasicService} from './helpers/before/feathers-hookup' 6 | import {addVueAndFeathers, vueAndFeathersCleanup} from './helpers/before/feathers-and-vue-hookup' 7 | 8 | test.beforeEach(addVueAndFeathers) 9 | test.beforeEach(addBasicService) 10 | test.beforeEach(t => { 11 | const Vue = t.context.Vue 12 | t.context.instance = new Vue({ 13 | data() { 14 | return { 15 | // To avoid vue-warn for setting paths on vm 16 | variables: {} 17 | } 18 | } 19 | }) 20 | 21 | t.context.createSyncer = function (settings) { 22 | return new CollectionSyncer(Vue, t.context.instance, 'test', settings) 23 | } 24 | }) 25 | 26 | test.afterEach(t => { 27 | if ('syncer' in t.context) { 28 | t.context.syncer.destroy() 29 | } 30 | }) 31 | test.afterEach(vueAndFeathersCleanup) 32 | 33 | test('Get filtered collection', async t => { 34 | const {createSyncer, instance} = t.context 35 | 36 | instance.$on('syncer-error', (path, error) => { 37 | t.fail(error) 38 | }) 39 | 40 | const syncer = createSyncer({ 41 | service: 'test', 42 | query() { 43 | return { 44 | otherItem: true 45 | } 46 | } 47 | }) 48 | t.context.syncer = syncer 49 | 50 | await syncer.ready() 51 | 52 | t.deepEqual(syncer.state, {2: {id: 2, otherItem: true}}) 53 | }) 54 | 55 | test('No results is just empty and no error', async t => { 56 | const {createSyncer, instance} = t.context 57 | 58 | instance.$on('syncer-error', (path, error) => { 59 | t.fail(error) 60 | }) 61 | 62 | const syncer = createSyncer({ 63 | service: 'test', 64 | query() { 65 | return { 66 | noItems: true 67 | } 68 | } 69 | }) 70 | t.context.syncer = syncer 71 | 72 | await syncer.ready() 73 | 74 | t.deepEqual(syncer.state, {}) 75 | }) 76 | 77 | test('Switching queries', async t => { 78 | const {callService, createSyncer, instance, Vue} = t.context 79 | 80 | Vue.set(instance.variables, 'query', {tested: true}) 81 | instance.$on('syncer-error', (path, error) => { 82 | t.fail(error) 83 | }) 84 | 85 | const syncer = createSyncer({ 86 | service: 'test', 87 | query() { 88 | return instance.variables.query 89 | } 90 | }) 91 | t.context.syncer = syncer 92 | 93 | await syncer.ready() 94 | 95 | t.falsy(syncer.loading) 96 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}}) 97 | 98 | // Null query: just cleared 99 | await new Promise(resolve => { 100 | instance.variables.query = null 101 | Vue.nextTick(() => { 102 | resolve() 103 | }) 104 | }) 105 | 106 | t.falsy(syncer.loading) 107 | t.deepEqual(syncer.state, {}) 108 | 109 | // Ensure that updates don't get reflected 110 | await callService('patch', 1, {another: 'yep'}) 111 | 112 | t.deepEqual(syncer.state, {}) 113 | 114 | // Change query 115 | await new Promise(resolve => { 116 | instance.$once('syncer-loaded', () => { 117 | resolve() 118 | }) 119 | instance.variables.query = {otherItem: true} 120 | }) 121 | 122 | t.falsy(syncer.loading) 123 | t.deepEqual(syncer.state, {2: {id: 2, otherItem: true}}) 124 | 125 | // Try to avoid re-querying whenver possible 126 | instance.$once('syncer-loaded', () => { 127 | t.fail('Queried again when test shouldn\'t') 128 | }) 129 | instance.variables.query = {otherItem: true} 130 | // Wait for watchers to do their thing 131 | await new Promise(resolve => { 132 | instance.$nextTick(() => { 133 | resolve() 134 | }) 135 | }) 136 | t.false(syncer.loading) 137 | }) 138 | 139 | test('Creating items', async t => { 140 | const {callService, createSyncer, instance} = t.context 141 | 142 | instance.$on('syncer-error', (path, error) => { 143 | t.fail(error) 144 | }) 145 | 146 | const syncer = createSyncer({ 147 | service: 'test', 148 | query() { 149 | return { 150 | tested: true 151 | } 152 | } 153 | }) 154 | t.context.syncer = syncer 155 | 156 | await syncer.ready() 157 | 158 | const should = {1: {id: 1, tested: true}} 159 | 160 | t.deepEqual(syncer.state, should) 161 | 162 | // Create item that matches 163 | const created = await callService('create', {tested: true, another: 'yep'}) 164 | should[created.id] = created 165 | 166 | t.deepEqual(syncer.state, should) 167 | 168 | // Create item that doesn't match (doesn't get added) 169 | await callService('create', {otherItem: true, another: 'yep'}) 170 | 171 | t.deepEqual(syncer.state, should) 172 | }) 173 | 174 | test('Updating items', async t => { 175 | const {callService, createSyncer, instance} = t.context 176 | 177 | instance.$on('syncer-error', (path, error) => { 178 | t.fail(error) 179 | }) 180 | 181 | const syncer = createSyncer({ 182 | service: 'test', 183 | query() { 184 | return { 185 | tested: true 186 | } 187 | } 188 | }) 189 | t.context.syncer = syncer 190 | 191 | await syncer.ready() 192 | 193 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}}) 194 | 195 | // Update item that matches 196 | await callService('update', 1, {id: 1, tested: true, another: 'yep'}) 197 | 198 | t.deepEqual(syncer.state, {1: {id: 1, tested: true, another: 'yep'}}) 199 | 200 | // Update item that doesn't match (is removed) 201 | await callService('update', 1, {id: 1, another: 'yep'}) 202 | 203 | t.deepEqual(syncer.state, {}) 204 | 205 | // Update item that didn't match (does nothing) 206 | await callService('update', 1, {id: 1, another: 'again'}) 207 | 208 | t.deepEqual(syncer.state, {}) 209 | 210 | // Update item that now matches 211 | await callService('update', 2, {id: 2, tested: true, otherItem: true}) 212 | 213 | t.deepEqual(syncer.state, {2: {id: 2, tested: true, otherItem: true}}) 214 | }) 215 | 216 | test('Patching items', async t => { 217 | const {callService, createSyncer, instance} = t.context 218 | 219 | instance.$on('syncer-error', (path, error) => { 220 | t.fail(error) 221 | }) 222 | 223 | const syncer = createSyncer({ 224 | service: 'test', 225 | query() { 226 | return { 227 | tested: true 228 | } 229 | } 230 | }) 231 | t.context.syncer = syncer 232 | 233 | await syncer.ready() 234 | 235 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}}) 236 | 237 | // Patch item that matches 238 | await callService('patch', 1, {another: 'yep'}) 239 | 240 | t.deepEqual(syncer.state, {1: {id: 1, tested: true, another: 'yep'}}) 241 | 242 | // Patch item that doesn't match (is removed) 243 | await callService('patch', 1, {tested: false}) 244 | 245 | t.deepEqual(syncer.state, {}) 246 | 247 | // Patch item that didn't match (does nothing) 248 | await callService('patch', 1, {tested: 'still not'}) 249 | 250 | t.deepEqual(syncer.state, {}) 251 | 252 | // Patch item that now matches 253 | await callService('patch', 2, {tested: true}) 254 | 255 | t.deepEqual(syncer.state, {2: {id: 2, tested: true, otherItem: true}}) 256 | }) 257 | 258 | test('Removing items', async t => { 259 | const {callService, createSyncer, instance} = t.context 260 | 261 | instance.$on('syncer-error', (path, error) => { 262 | t.fail(error) 263 | }) 264 | 265 | const syncer = createSyncer({ 266 | service: 'test', 267 | query() { 268 | return { 269 | tested: true 270 | } 271 | } 272 | }) 273 | t.context.syncer = syncer 274 | 275 | await syncer.ready() 276 | 277 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}}) 278 | 279 | // Remove item that matches 280 | await callService('remove', 1) 281 | 282 | t.deepEqual(syncer.state, {}) 283 | 284 | // Remove item that doesn't match (does nothing) 285 | await callService('remove', 2) 286 | 287 | t.deepEqual(syncer.state, {}) 288 | }) 289 | 290 | test('$select', async t => { 291 | const {callService, createSyncer, instance} = t.context 292 | 293 | instance.$on('syncer-error', (path, error) => { 294 | t.fail(error) 295 | }) 296 | 297 | const syncer = createSyncer({ 298 | service: 'test', 299 | query() { 300 | return { 301 | $select: ['id', 'tested'] 302 | } 303 | } 304 | }) 305 | t.context.syncer = syncer 306 | 307 | await syncer.ready() 308 | 309 | // Sockets usually strip undefined values but it's still a thing here 310 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}, 2: {id: 2, tested: undefined}}) 311 | 312 | // Insert item 313 | await callService('create', { 314 | tested: 'created', 315 | extra: false 316 | }) 317 | 318 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}, 2: {id: 2, tested: undefined}, 3: {id: 3, tested: 'created'}}) 319 | 320 | // Update 321 | await callService('update', 2, { 322 | id: 2, 323 | tested: 'updated', 324 | otherItem: true 325 | }) 326 | 327 | t.deepEqual(syncer.state, {1: {id: 1, tested: true}, 2: {id: 2, tested: 'updated'}, 3: {id: 3, tested: 'created'}}) 328 | 329 | // Patch 330 | await callService('patch', 1, { 331 | tested: 'patched', 332 | extraStuff: true 333 | }) 334 | 335 | t.deepEqual(syncer.state, {1: {id: 1, tested: 'patched'}, 2: {id: 2, tested: 'updated'}, 3: {id: 3, tested: 'created'}}) 336 | }) 337 | -------------------------------------------------------------------------------- /test/core.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {addVueWithPlugin, vueCleanup} from './helpers/before/vue-hookup' 3 | 4 | function makeBaseDriver() { 5 | return class TestDriver { 6 | constructor(Vue) { 7 | Vue.util.defineReactive(this, 'state', {}) 8 | } 9 | 10 | ready() { 11 | } 12 | 13 | destroy() { 14 | } 15 | } 16 | } 17 | 18 | test.beforeEach(t => { 19 | addVueWithPlugin(t, {driver: makeBaseDriver(), feathers: {}}) 20 | }) 21 | 22 | test.afterEach(vueCleanup) 23 | 24 | test.cb('Syncer lifecycle methods are called in right order', t => { 25 | const Vue = t.context.Vue 26 | 27 | t.plan(7) 28 | let order = 0 29 | 30 | class TestSyncer extends makeBaseDriver() { 31 | constructor(Vue) { 32 | super(Vue) 33 | 34 | t.is(order++, 0, 'Syncer instance set up') 35 | } 36 | 37 | ready() { 38 | super.ready() 39 | 40 | t.is(order++, 2, 'Syncer can be ready') 41 | } 42 | 43 | destroy() { 44 | super.destroy() 45 | 46 | t.is(order++, 4, 'Syncer being destroyed') 47 | } 48 | } 49 | 50 | Vue.$syncer.driver = TestSyncer 51 | 52 | const instance = new Vue({ 53 | beforeCreate() { 54 | t.is(order++, 1, 'Vue instance created') 55 | }, 56 | 57 | created() { 58 | // No ready in node mode 59 | t.is(order++, 3, 'Vue instance is ready') 60 | 61 | Vue.nextTick(() => { 62 | instance.$destroy() 63 | }) 64 | }, 65 | 66 | beforeDestroy() { 67 | t.is(order++, 5, 'Vue instance being destroyed') 68 | }, 69 | 70 | destroyed() { 71 | t.is(order++, 6, 'Vue instance is destroyed') 72 | 73 | Vue.nextTick(() => { 74 | // Make sure hook doesn't cause double cleanup for any weird reason 75 | instance.$destroy() 76 | 77 | Vue.nextTick(() => { 78 | t.end() 79 | }) 80 | }) 81 | }, 82 | 83 | sync: { 84 | test: 'test' 85 | } 86 | }) 87 | }) 88 | 89 | test.cb('Non-used instances work fine', t => { 90 | const Vue = t.context.Vue 91 | 92 | t.truthy(Vue.$syncer) 93 | 94 | const instance = new Vue({ 95 | destroyed() { 96 | t.pass() 97 | 98 | Vue.nextTick(() => { 99 | t.end() 100 | }) 101 | } 102 | }) 103 | // No syncers = not loading 104 | t.falsy(instance.$loadingSyncers) 105 | instance.$destroy() 106 | }) 107 | -------------------------------------------------------------------------------- /test/feathers.core.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {addVueWithPlugin, vueCleanup} from './helpers/before/vue-hookup' 3 | 4 | test.afterEach(vueCleanup) 5 | 6 | test('Throws error if no feathers client set', t => { 7 | t.throws(() => { 8 | addVueWithPlugin(t) 9 | }, 'No feathers instance set in options') 10 | }) 11 | -------------------------------------------------------------------------------- /test/helpers/before/feathers-and-vue-hookup.js: -------------------------------------------------------------------------------- 1 | // So many netflix and chill jokes, so little time 2 | import {addFeathersInstance, feathersCleanup} from './feathers-hookup' 3 | import {addVueWithPlugin, vueCleanup} from './vue-hookup' 4 | 5 | export async function addVueAndFeathers(t) { 6 | await addFeathersInstance(t) 7 | addVueWithPlugin(t, {feathers: t.context.client}) 8 | } 9 | 10 | export function vueAndFeathersCleanup(t) { 11 | vueCleanup(t) 12 | feathersCleanup(t) 13 | } 14 | -------------------------------------------------------------------------------- /test/helpers/before/feathers-hookup.js: -------------------------------------------------------------------------------- 1 | import {Service} from 'feathers-memory' 2 | import cloneDeep from 'lodash/cloneDeep' 3 | import feathersTestServer from '../feathers-server' 4 | 5 | export async function addFeathersInstance(t) { 6 | // Feathers 7 | const {server, getClient} = feathersTestServer() 8 | 9 | t.context.server = server 10 | t.context.getClient = getClient 11 | t.context.client = await getClient() 12 | } 13 | 14 | const methodToEvent = { 15 | create: 'created', 16 | update: 'updated', 17 | patch: 'patched', 18 | remove: 'removed' 19 | } 20 | 21 | export function addBasicService(t) { 22 | t.context.server.service('test', new Service({ 23 | startId: 3, 24 | store: cloneDeep({ 25 | 1: { 26 | id: 1, 27 | tested: true 28 | }, 29 | 2: { 30 | id: 2, 31 | otherItem: true 32 | } 33 | }) 34 | })) 35 | t.context.service = t.context.server.service('test') 36 | // Call service and don't resolve until clients have been notified 37 | t.context.callService = (method, ...params) => { 38 | const eventPromise = new Promise((resolve, reject) => { 39 | const event = methodToEvent[method] 40 | if (!event) { 41 | return resolve() 42 | } 43 | 44 | const failedTimeout = setTimeout(() => { 45 | reject(new Error('Waiting for event timed out')) 46 | }, 5000) 47 | t.context.client.service('test').once(event, () => { 48 | clearTimeout(failedTimeout) 49 | resolve() 50 | }) 51 | }) 52 | 53 | return Promise.resolve(t.context.service[method](...params)) 54 | .then(result => { 55 | return eventPromise.then(() => result) 56 | }) 57 | } 58 | } 59 | 60 | export function addPaginatedService(t) { 61 | t.context.server.service('paginated', new Service({ 62 | paginate: { 63 | default: 3, 64 | max: 10 65 | }, 66 | startId: 6, 67 | store: cloneDeep({ 68 | 1: { 69 | id: 1, 70 | item: 'first' 71 | }, 72 | 2: { 73 | id: 2, 74 | item: 'second' 75 | }, 76 | 3: { 77 | id: 3, 78 | item: 'third' 79 | }, 80 | 4: { 81 | id: 4, 82 | item: 'fourth' 83 | }, 84 | 5: { 85 | id: 5, 86 | item: 'fifth' 87 | } 88 | }) 89 | })) 90 | } 91 | 92 | export function feathersCleanup(t) { 93 | t.context.server.io.close() 94 | } 95 | -------------------------------------------------------------------------------- /test/helpers/before/vue-hookup.js: -------------------------------------------------------------------------------- 1 | import BaseVue from 'vue' 2 | import VueSyncersFeathers from '../../../src' 3 | 4 | // If a vue error happens log extra info on the error 5 | BaseVue.config.errorHandler = function (err, vm) { 6 | const t = Object.getPrototypeOf(vm).constructor.test 7 | console.log('Test: ', t._test.title) 8 | console.error(err) 9 | } 10 | 11 | export function addVueWithPlugin(t, options) { 12 | const Vue = BaseVue.extend() 13 | t.context.Vue = Vue 14 | 15 | // Because we're installing onto extended vue instance copy global methods to new instance 16 | Vue.version = BaseVue.version 17 | Vue.util = BaseVue.util 18 | Vue.set = BaseVue.set 19 | Vue.delete = BaseVue.delete 20 | Vue.nextTick = BaseVue.nextTick 21 | Vue.config = BaseVue.config // Not cloned 22 | Vue.test = t 23 | // To reference the right Vue instance 24 | Vue.mixin = function (mixin) { 25 | Vue.options = Vue.util.mergeOptions(Vue.options, mixin) 26 | } 27 | 28 | BaseVue.use.call(Vue, {install: VueSyncersFeathers.install}, options) 29 | } 30 | 31 | export function vueCleanup(t) { 32 | if (t.context.instance) { 33 | t.context.instance.$destroy() 34 | delete t.context.instance 35 | } 36 | if (t.context.Vue) { 37 | delete t.context.Vue 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/helpers/feathers-server.js: -------------------------------------------------------------------------------- 1 | import feathers from 'feathers' 2 | import feathersClient from 'feathers/client' 3 | import feathersSocketIOclient from 'feathers-socketio/client' 4 | import localSocketer from './feathers-socket' 5 | import {SocketIO} from './mock-socket' 6 | 7 | function localClient(url) { 8 | const connection = new SocketIO(url) 9 | // Fool feathers into thinking it's socketio 10 | connection.io = true 11 | 12 | return feathersSocketIOclient(connection) 13 | } 14 | 15 | let instance = 8901 16 | 17 | export default function () { 18 | const server = feathers() 19 | const url = 'http://localtest:' + instance++ 20 | 21 | server.configure(localSocketer(url)) 22 | 23 | // Services can be bound late 24 | 25 | // Manually call server setup method 26 | server.setup() 27 | 28 | return { 29 | server, 30 | getClient: (awaiting = true) => { 31 | const client = feathersClient().configure(localClient(url)) 32 | if (awaiting) { 33 | return new Promise((resolve, reject) => { 34 | client.io.on('connect', () => { 35 | resolve(client) 36 | }) 37 | client.io.on('close', error => { 38 | reject(error) 39 | }) 40 | }) 41 | } 42 | 43 | return client 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/helpers/feathers-socket.js: -------------------------------------------------------------------------------- 1 | import Proto from 'uberproto' 2 | import socket from 'feathers-socket-commons' 3 | import {Server} from './mock-socket' 4 | 5 | /** 6 | * Mocks a connection between client and server 7 | * 8 | * Based on feathers-socketio 9 | * 10 | * @param {String} url 11 | * @returns {Function} 12 | */ 13 | export default function localSocketer(url) { 14 | return function () { 15 | const app = this 16 | 17 | app.configure(socket('io')) 18 | 19 | Proto.mixin({ 20 | setup() { 21 | const io = new Server(url) 22 | this.io = io 23 | 24 | io.on('connection', socket => { 25 | socket.feathers = { 26 | provider: 'socketio' 27 | } 28 | }) 29 | 30 | this._socketInfo = { 31 | method: 'emit', 32 | connection() { 33 | return io 34 | }, 35 | clients() { 36 | return io.clients() 37 | }, 38 | params(socket) { 39 | return socket.feathers 40 | } 41 | } 42 | 43 | return this._super.apply(this, arguments) 44 | } 45 | }, app) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/helpers/global-require.js: -------------------------------------------------------------------------------- 1 | // Run mock-socket-src thorugh babel 2 | const path = require('path') 3 | 4 | require('babel-register')({ 5 | ignore: /node_modules(?!\/mock-socket\/src)/, 6 | extends: path.resolve(__dirname, '../../.babelrc') 7 | }) 8 | 9 | -------------------------------------------------------------------------------- /test/helpers/mock-socket.js: -------------------------------------------------------------------------------- 1 | import {SocketIO as BaseSocketIOConstructor, Server as BaseServer} from 'mock-socket' 2 | import {createMessageEvent} from 'mock-socket/src/event-factory' 3 | import cloneDeepWith from 'lodash/cloneDeepWith' 4 | 5 | export class Server extends BaseServer { 6 | // SocketIO sends server (this) as first arg to connection & connect events, this fixes it 7 | dispatchEvent(event, ...customArguments) { 8 | if (customArguments[0] && customArguments[0] === this) { 9 | customArguments.shift() 10 | } 11 | return super.dispatchEvent(event, ...customArguments) 12 | } 13 | } 14 | 15 | // SocketIO class isn't exposed 16 | const serverInstance = new Server('dummy') 17 | const instance = new BaseSocketIOConstructor('dummy') 18 | const BaseSocketIO = Object.getPrototypeOf(instance).constructor 19 | instance.on('connect', () => { 20 | instance.close() 21 | serverInstance.close() 22 | }) 23 | // GG 24 | 25 | function cloneCustomiser(arg) { 26 | if (typeof arg === 'function') { 27 | return function (...args) { 28 | args = cloneDeepWith(args, cloneCustomiser) 29 | return arg(...args) 30 | } 31 | } 32 | return undefined 33 | } 34 | 35 | export class SocketIO extends BaseSocketIO { 36 | 37 | // Allow more than 1 arg 38 | emit(event, ...data) { 39 | if (this.readyState !== BaseSocketIO.OPEN) { 40 | throw new Error('SocketIO is already in CLOSING or CLOSED state') 41 | } 42 | 43 | // Emulate connection by re-creating all objects 44 | data = cloneDeepWith(data, cloneCustomiser) 45 | 46 | const messageEvent = createMessageEvent({ 47 | type: event, 48 | origin: this.url, 49 | data 50 | }) 51 | 52 | // Dispatch on self since the event listeners are added to per connection 53 | this.dispatchEvent(messageEvent, ...data) 54 | } 55 | 56 | once(type, callback) { 57 | const wrapped = (...args) => { 58 | this.removeEventListener(type, wrapped) 59 | return callback(...args) 60 | } 61 | return this.on(type, wrapped) 62 | } 63 | 64 | off(...args) { 65 | this.removeEventListener(...args) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/helpers/util.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/t2t2/vue-syncers-feathers/9a5c45d803d51d6c941dfdf2a729dc2908b53dc7/test/helpers/util.js -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {addVueAndFeathers, vueAndFeathersCleanup} from './helpers/before/feathers-and-vue-hookup' 4 | import {addBasicService} from './helpers/before/feathers-hookup' 5 | 6 | test.beforeEach(addVueAndFeathers) 7 | test.beforeEach(addBasicService) 8 | 9 | test.afterEach(vueAndFeathersCleanup) 10 | 11 | test.cb('Use single item syncer if requested', t => { 12 | const {Vue} = t.context 13 | 14 | t.context.instance = new Vue({ 15 | sync: { 16 | testVar: { 17 | service: 'test', 18 | id() { 19 | return 1 20 | } 21 | } 22 | }, 23 | created() { 24 | this.$on('syncer-loaded', path => { 25 | t.is(path, 'testVar') 26 | t.deepEqual(this.testVar, {id: 1, tested: true}) 27 | t.end() 28 | }) 29 | this.$on('syncer-error', (path, error) => { 30 | console.error(path, error) 31 | t.fail(error) 32 | t.end() 33 | }) 34 | } 35 | }) 36 | }) 37 | 38 | test.cb('Cleanup', t => { 39 | const {client, Vue} = t.context 40 | 41 | const instance = new Vue({ 42 | sync: { 43 | test: 'test' 44 | }, 45 | created() { 46 | this.$on('syncer-loaded', () => { 47 | Vue.nextTick(() => { 48 | instance.$destroy() 49 | }) 50 | }) 51 | this.$on('syncer-error', (path, error) => { 52 | t.fail(error) 53 | t.end() 54 | }) 55 | }, 56 | destroyed() { 57 | function checkEventListenersAreEmpty(event) { 58 | if (client.io.listeners['test ' + event]) { 59 | t.is(client.io.listeners['test ' + event].length, 0) 60 | } else { 61 | t.pass() 62 | } 63 | } 64 | 65 | checkEventListenersAreEmpty('created') 66 | checkEventListenersAreEmpty('updated') 67 | checkEventListenersAreEmpty('patched') 68 | checkEventListenersAreEmpty('removed') 69 | 70 | // Syncer value is null after deletion 71 | t.deepEqual(this.test, null) 72 | 73 | t.end() 74 | } 75 | }) 76 | t.context.instance = instance 77 | }) 78 | 79 | test.cb('Synced key can\'t be directly overwritten', t => { 80 | const {Vue} = t.context 81 | 82 | t.context.instance = new Vue({ 83 | sync: { 84 | test: 'test' 85 | }, 86 | created() { 87 | this.$on('syncer-loaded', () => { 88 | Vue.nextTick(() => { 89 | this.test = 'Failed' 90 | 91 | t.not(this.test, 'Failed') 92 | 93 | t.end() 94 | }) 95 | }) 96 | this.$on('syncer-error', (path, error) => { 97 | t.fail(error) 98 | t.end() 99 | }) 100 | } 101 | }) 102 | }) 103 | 104 | test.cb('Syncer can be configured in mixins', t => { 105 | const {Vue} = t.context 106 | 107 | t.context.instance = new Vue({ 108 | mixins: [ 109 | { 110 | sync: { 111 | mixedIn: 'test', 112 | overwritten: { 113 | service: 'test', 114 | id() { 115 | return 2 116 | } 117 | } 118 | } 119 | } 120 | ], 121 | sync: { 122 | overwritten: { 123 | service: 'test', 124 | id() { 125 | return 1 126 | } 127 | }, 128 | independant: 'test' 129 | }, 130 | created() { 131 | this.$on('syncer-loaded', () => { 132 | if (this.$loadingSyncers) { 133 | return // Wait for all 134 | } 135 | 136 | t.deepEqual(this.mixedIn, {1: {id: 1, tested: true}, 2: {id: 2, otherItem: true}}) 137 | t.deepEqual(this.overwritten, {id: 1, tested: true}) 138 | t.deepEqual(this.independant, {1: {id: 1, tested: true}, 2: {id: 2, otherItem: true}}) 139 | t.end() 140 | }) 141 | this.$on('syncer-error', (path, error) => { 142 | t.fail(error) 143 | t.end() 144 | }) 145 | } 146 | }) 147 | }) 148 | 149 | test('Refresh syncers', t => { 150 | const {service, Vue} = t.context 151 | 152 | // Don't send out events, callService won't work here 153 | service.filter(() => false) 154 | 155 | let instance 156 | 157 | async function runTests() { 158 | // Patch all 159 | await Promise.all([ 160 | service.patch(1, {updated: 1}), 161 | service.patch(2, {updated: 1}) 162 | ]) 163 | 164 | // Ensure update didn't get forwarded 165 | t.deepEqual(instance.testCol, {1: {id: 1, tested: true}, 2: {id: 2, otherItem: true}}) 166 | t.deepEqual(instance.testVar, {id: 1, tested: true}) 167 | 168 | // Update one 169 | await instance.$refreshSyncers('testCol') 170 | t.deepEqual(instance.testCol, {1: {id: 1, tested: true, updated: 1}, 2: {id: 2, otherItem: true, updated: 1}}) 171 | t.deepEqual(instance.testVar, {id: 1, tested: true}) 172 | 173 | // Update array 174 | await Promise.all([ 175 | service.patch(1, {updated: 2}), 176 | service.patch(2, {updated: 2}) 177 | ]) 178 | await instance.$refreshSyncers(['testCol', 'testVar']) 179 | t.deepEqual(instance.testCol, {1: {id: 1, tested: true, updated: 2}, 2: {id: 2, otherItem: true, updated: 2}}) 180 | t.deepEqual(instance.testVar, {id: 1, tested: true, updated: 2}) 181 | 182 | // Update all 183 | await Promise.all([ 184 | service.patch(1, {updated: 3}), 185 | service.patch(2, {updated: 3}) 186 | ]) 187 | await instance.$refreshSyncers() 188 | t.deepEqual(instance.testCol, {1: {id: 1, tested: true, updated: 3}, 2: {id: 2, otherItem: true, updated: 3}}) 189 | t.deepEqual(instance.testVar, {id: 1, tested: true, updated: 3}) 190 | } 191 | 192 | return new Promise((resolve, reject) => { 193 | instance = new Vue({ 194 | sync: { 195 | testCol: { 196 | service: 'test' 197 | }, 198 | testVar: { 199 | service: 'test', 200 | id() { 201 | return 1 202 | } 203 | } 204 | }, 205 | created() { 206 | const loaded = () => { 207 | if (this.$loadingSyncers) { 208 | return // Wait for all 209 | } 210 | 211 | this.$off('syncer-loaded', loaded) 212 | resolve(runTests()) 213 | } 214 | 215 | this.$on('syncer-loaded', loaded) 216 | this.$on('syncer-error', (path, error) => { 217 | console.error(path, error) 218 | reject(error) 219 | }) 220 | } 221 | }) 222 | t.context.instance = instance 223 | }) 224 | }) 225 | 226 | test.cb('Events can be registerred on syncer settings', t => { 227 | const {Vue} = t.context 228 | 229 | t.plan(4) 230 | 231 | const instance = new Vue({ 232 | sync: { 233 | passing: { 234 | service: 'test', 235 | id() { 236 | return 1 237 | }, 238 | loaded() { 239 | t.deepEqual(instance.passing, {id: 1, tested: true}) 240 | t.is(this, instance) 241 | }, 242 | errored(err) { 243 | t.fail(err) 244 | } 245 | }, 246 | failing: { 247 | service: 'test', 248 | id() { 249 | return 10 250 | }, 251 | loaded() { 252 | t.fail() 253 | }, 254 | errored(err) { 255 | t.pass(err) 256 | t.is(this, instance) 257 | } 258 | } 259 | }, 260 | created() { 261 | const handleLoaded = () => { 262 | if (this.$loadingSyncers) { 263 | return // Wait for all 264 | } 265 | 266 | this.$nextTick(() => { 267 | t.end() 268 | }) 269 | } 270 | 271 | this.$on('syncer-loaded', handleLoaded) 272 | this.$on('syncer-error', handleLoaded) 273 | } 274 | }) 275 | t.context.instance = instance 276 | }) 277 | 278 | -------------------------------------------------------------------------------- /test/item.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | 3 | import {Service} from 'feathers-memory' 4 | 5 | import ItemSyncer from '../src/syncers/item' 6 | 7 | import {addBasicService} from './helpers/before/feathers-hookup' 8 | import {addVueAndFeathers, vueAndFeathersCleanup} from './helpers/before/feathers-and-vue-hookup' 9 | 10 | test.beforeEach(addVueAndFeathers) 11 | test.beforeEach(addBasicService) 12 | test.beforeEach(t => { 13 | const Vue = t.context.Vue 14 | t.context.instance = new Vue({ 15 | data() { 16 | return { 17 | // To avoid vue-warn for setting paths on vm 18 | variables: {} 19 | } 20 | } 21 | }) 22 | 23 | t.context.createSyncer = function (settings) { 24 | return new ItemSyncer(Vue, t.context.instance, 'test', settings) 25 | } 26 | }) 27 | 28 | test.afterEach(t => { 29 | if ('syncer' in t.context) { 30 | t.context.syncer.destroy() 31 | } 32 | }) 33 | test.afterEach(vueAndFeathersCleanup) 34 | 35 | test('Get an item', async t => { 36 | const {instance, createSyncer} = t.context 37 | 38 | instance.$on('syncer-error', (path, error) => { 39 | t.fail(error) 40 | }) 41 | 42 | const syncer = createSyncer({ 43 | service: 'test', 44 | id() { 45 | return 1 46 | } 47 | }) 48 | t.context.syncer = syncer 49 | 50 | t.plan(4) 51 | 52 | // Loading by default 53 | t.truthy(syncer.loading) 54 | 55 | instance.$once('syncer-loaded', path => { 56 | // Correct path 57 | t.is(path, 'test') 58 | }) 59 | 60 | await syncer.ready() 61 | 62 | t.falsy(syncer.loading) 63 | t.deepEqual(syncer.state, {id: 1, tested: true}) 64 | }) 65 | 66 | test('Undefined items set null and send error', async t => { 67 | const {instance, createSyncer} = t.context 68 | 69 | instance.$on('syncer-loaded', () => { 70 | t.fail('Loaded something') 71 | }) 72 | 73 | const syncer = createSyncer({ 74 | service: 'test', 75 | id() { 76 | return 3 77 | } 78 | }) 79 | t.context.syncer = syncer 80 | 81 | t.plan(4) 82 | 83 | instance.$once('syncer-error', (path, error) => { 84 | t.is(path, 'test') 85 | t.truthy(error) 86 | }) 87 | 88 | await syncer.ready() 89 | 90 | t.falsy(syncer.loading) 91 | t.deepEqual(syncer.state, null) 92 | }) 93 | 94 | test('Switching items', async t => { 95 | const {instance, createSyncer, Vue} = t.context 96 | 97 | Vue.set(instance.variables, 'itemId', 1) 98 | instance.$on('syncer-error', (path, error) => { 99 | t.fail(error) 100 | }) 101 | 102 | const syncer = createSyncer({ 103 | service: 'test', 104 | id() { 105 | return instance.variables.itemId 106 | } 107 | }) 108 | t.context.syncer = syncer 109 | 110 | await syncer.ready() 111 | 112 | t.falsy(syncer.loading) 113 | t.deepEqual(syncer.state, {id: 1, tested: true}) 114 | 115 | // Test null id (should just clear the target) 116 | await new Promise(resolve => { 117 | instance.variables.itemId = null 118 | Vue.nextTick(() => { 119 | resolve() 120 | }) 121 | }) 122 | 123 | t.falsy(syncer.loading) 124 | t.is(syncer.state, null) 125 | 126 | // Promiseify next loading 127 | await new Promise(resolve => { 128 | instance.$once('syncer-loaded', () => { 129 | resolve() 130 | }) 131 | instance.variables.itemId = 2 132 | }) 133 | 134 | t.falsy(syncer.loading) 135 | t.deepEqual(syncer.state, {id: 2, otherItem: true}) 136 | }) 137 | 138 | /* 139 | * I mean.... You shouuuuuuldn't..... But you shouldn't intentionally.... Like okay it may happen 140 | * I'll reserve the right to judge you for doing this. But I'll probably end up doing the same somewhere 141 | */ 142 | test('Creating items', async t => { 143 | const {callService, createSyncer, instance} = t.context 144 | 145 | instance.$on('syncer-loaded', () => { 146 | t.fail('Loaded something') 147 | }) 148 | 149 | const syncer = createSyncer({ 150 | service: 'test', 151 | id() { 152 | return 3 153 | } 154 | }) 155 | t.context.syncer = syncer 156 | 157 | t.plan(3) 158 | 159 | // Most of this is already tested in other places 160 | instance.$once('syncer-error', () => { 161 | t.pass() 162 | }) 163 | await syncer.ready() 164 | 165 | t.is(syncer.state, null) 166 | 167 | // Create the item 168 | const created = await callService('create', {created: 'Ok'}) 169 | 170 | t.deepEqual(syncer.state, created) 171 | }) 172 | 173 | test('Update item', async t => { 174 | const {callService, createSyncer, instance} = t.context 175 | 176 | instance.$on('syncer-error', (path, error) => { 177 | t.fail(error) 178 | }) 179 | 180 | const syncer = createSyncer({ 181 | service: 'test', 182 | id() { 183 | return 1 184 | } 185 | }) 186 | t.context.syncer = syncer 187 | 188 | await syncer.ready() 189 | 190 | t.falsy(syncer.loading) 191 | t.deepEqual(syncer.state, {id: 1, tested: true}) 192 | 193 | await callService('update', 1, {updated: true}) 194 | 195 | t.deepEqual(syncer.state, {id: 1, updated: true}) 196 | }) 197 | 198 | test('Patch item', async t => { 199 | const {callService, createSyncer, instance} = t.context 200 | 201 | instance.$on('syncer-error', (path, error) => { 202 | t.fail(error) 203 | }) 204 | 205 | const syncer = createSyncer({ 206 | service: 'test', 207 | id() { 208 | return 1 209 | } 210 | }) 211 | t.context.syncer = syncer 212 | 213 | await syncer.ready() 214 | 215 | t.falsy(syncer.loading) 216 | t.deepEqual(syncer.state, {id: 1, tested: true}) 217 | 218 | await callService('patch', 1, {updated: true}) 219 | 220 | t.deepEqual(syncer.state, {id: 1, tested: true, updated: true}) 221 | }) 222 | 223 | test('Delete item', async t => { 224 | const {callService, createSyncer, instance} = t.context 225 | 226 | instance.$on('syncer-error', (path, error) => { 227 | t.fail(error) 228 | }) 229 | 230 | const syncer = createSyncer({ 231 | service: 'test', 232 | id() { 233 | return 1 234 | } 235 | }) 236 | t.context.syncer = syncer 237 | 238 | await syncer.ready() 239 | 240 | t.falsy(syncer.loading) 241 | t.deepEqual(syncer.state, {id: 1, tested: true}) 242 | 243 | await callService('remove', 1) 244 | 245 | t.deepEqual(syncer.state, null) 246 | }) 247 | 248 | test('Updates to other items don\'t affect the tracked item', async t => { 249 | const {callService, createSyncer, instance, service} = t.context 250 | 251 | instance.$on('syncer-error', (path, error) => { 252 | t.fail(error) 253 | }) 254 | 255 | await service.create([{premade: true}, {anotherPremade: true}]) 256 | 257 | const syncer = createSyncer({ 258 | service: 'test', 259 | id() { 260 | return 1 261 | } 262 | }) 263 | t.context.syncer = syncer 264 | 265 | await syncer.ready() 266 | 267 | t.falsy(syncer.loading) 268 | t.deepEqual(syncer.state, {id: 1, tested: true}) 269 | 270 | await Promise.all([ 271 | callService('create', {created: true}), 272 | callService('update', 2, {updated: true}), 273 | callService('patch', 3, {patched: true}), 274 | callService('remove', 4) 275 | ]) 276 | 277 | t.deepEqual(syncer.state, {id: 1, tested: true}) 278 | }) 279 | 280 | test('Custom id field', async t => { 281 | const {server, createSyncer} = t.context 282 | 283 | server.service('custom', new Service({ 284 | idField: 'known', 285 | startId: 2, 286 | store: { 287 | 1: { 288 | known: 1, 289 | id: 99, 290 | idTest: true 291 | } 292 | } 293 | })) 294 | 295 | const syncer = createSyncer({ 296 | service: 'custom', 297 | id() { 298 | return 1 299 | }, 300 | idField: 'known' 301 | }) 302 | t.context.syncer = syncer 303 | 304 | await syncer.ready() 305 | 306 | t.falsy(syncer.loading) 307 | t.deepEqual(syncer.state, {known: 1, id: 99, idTest: true}) 308 | }) 309 | 310 | test('Handle destruction while loading', async t => { 311 | const {createSyncer} = t.context 312 | 313 | const syncer = createSyncer({ 314 | service: 'test', 315 | id() { 316 | return 1 317 | } 318 | }) 319 | 320 | const synced = syncer.ready() 321 | syncer.destroy() 322 | await synced 323 | 324 | t.pass() 325 | }) 326 | -------------------------------------------------------------------------------- /test/tooling.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import {Service} from 'feathers-memory' 3 | import {addFeathersInstance, feathersCleanup} from './helpers/before/feathers-hookup' 4 | 5 | test.beforeEach(addFeathersInstance) 6 | 7 | test.afterEach(feathersCleanup) 8 | 9 | function defaultItem() { 10 | return { 11 | id: 1, 12 | tested: true 13 | } 14 | } 15 | 16 | function testService() { 17 | return new Service({ 18 | startId: 2, 19 | store: { 20 | 1: defaultItem() 21 | } 22 | }) 23 | } 24 | 25 | test('Test the feathers testing server', async t => { 26 | const {server, client} = t.context 27 | 28 | server.service('test', testService()) 29 | 30 | // Getting items 31 | const item = await client.service('test').get(1) 32 | 33 | t.deepEqual(item, {id: 1, tested: true}) 34 | 35 | // Events emitted 36 | await new Promise((resolve, reject) => { 37 | let result 38 | 39 | function matches(value) { 40 | // First call sets, second tests 41 | if (result) { 42 | t.deepEqual(result, value) 43 | } else { 44 | result = value 45 | } 46 | } 47 | 48 | client.service('test').on('created', item => { 49 | matches(item) 50 | 51 | resolve() 52 | }) 53 | 54 | client.service('test').create({ 55 | tested: 'Ok' 56 | }).then(item => { 57 | matches(item) 58 | }).catch(err => { 59 | t.fail(err) 60 | reject() 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as utils from '../src/utils' 3 | 4 | test('isNumberLike', t => { 5 | t.truthy(utils.isNumericIDLike('2')) 6 | 7 | // Ignore numbers 8 | t.falsy(utils.isNumericIDLike(1)) 9 | t.falsy(utils.isNumericIDLike(1.2)) 10 | // Ignore non-id 11 | t.falsy(utils.isNumericIDLike('2.6')) 12 | // Some sort of UUID that only has numbers (but should be string) 13 | t.falsy(utils.isNumericIDLike('132-3534-23')) 14 | }) 15 | --------------------------------------------------------------------------------