├── .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 | [](https://travis-ci.org/t2t2/vue-syncers-feathers)
6 | [](https://coveralls.io/github/t2t2/vue-syncers-feathers?branch=master)
7 | [](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 |
53 |
54 |
55 | {{user | json}}
56 |
57 |
58 |
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 |
186 |
187 |
Loading...
188 | [...]
189 |
190 |
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 |
2 |
3 |
vue-syncers-feathers demo page
4 |
5 | Basic todo list
6 |
7 |
8 | One item
9 |
10 |
11 | Countries
12 |
13 |
14 |
15 |
16 |
29 |
30 |
45 |
--------------------------------------------------------------------------------
/example/components/countries.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 | AA
11 | Name
12 | Languages
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
77 |
78 |
111 |
--------------------------------------------------------------------------------
/example/components/single.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Item:
5 |
6 |
7 |
{{ error.message }}
8 |
9 |
10 |
11 | Loading...
12 |
13 |
14 |
15 | ID: {{item.id}}
16 | Title: {{item.title}}
17 | Completed: {{item.completed}}
18 |
19 |
20 | No item found
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
68 |
--------------------------------------------------------------------------------
/example/components/todos.vue:
--------------------------------------------------------------------------------
1 |
2 |
44 |
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 |
--------------------------------------------------------------------------------