├── static └── .gitkeep ├── .eslintignore ├── test ├── unit │ ├── setup.js │ ├── .eslintrc │ ├── jest.conf.js │ └── specs │ │ ├── mapGetters.spec.js │ │ ├── plugin.spec.js │ │ ├── mapActions.spec.js │ │ ├── getters.spec.js │ │ ├── utils.spec.js │ │ ├── SearchApi.spec.js │ │ └── VuexSearch.spec.js └── e2e │ ├── specs │ └── test.js │ ├── custom-assertions │ └── elementCount.js │ ├── nightwatch.conf.js │ └── runner.js ├── config ├── prod.env.js ├── test.env.js ├── dev.env.js └── index.js ├── web ├── assets │ ├── vuex-search-header.png │ ├── vuex-search_icons.png │ └── loading-cubes.svg ├── store │ ├── getters.js │ ├── mutation-types.js │ ├── mutations.js │ ├── index.js │ └── actions.js ├── main.js ├── worker │ └── generate-data.js ├── components │ ├── StyledButton.vue │ ├── InputField.vue │ ├── ContactDetail.vue │ └── ListContacts.vue └── App.vue ├── codecov.yml ├── .editorconfig ├── .travis.yml ├── .gitignore ├── .postcssrc.js ├── src ├── mutation-types.js ├── action-types.js ├── getters.js ├── getter-types.js ├── index.js ├── plugin.js ├── mapGetters.js ├── mutations.js ├── mapActions.js ├── actions.js ├── utils.js ├── SearchApi.js └── VuexSearch.js ├── types ├── tsconfig.json ├── VuexSearch.d.ts ├── mappers.d.ts ├── index.d.ts └── SearchApi.d.ts ├── .npmignore ├── index.html ├── LICENSE ├── .babelrc ├── CONTRIBUTING.md ├── .eslintrc.js ├── CHANGELOG.md ├── package.json └── README.md /static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /config/ 3 | /dist/ 4 | /*.js 5 | /test/unit/coverage/ 6 | -------------------------------------------------------------------------------- /test/unit/setup.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | Vue.config.productionTip = false; 4 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | module.exports = { 3 | NODE_ENV: '"production"' 4 | } 5 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | }, 5 | "globals": { 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /web/assets/vuex-search-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbertLucianto/vuex-search/HEAD/web/assets/vuex-search-header.png -------------------------------------------------------------------------------- /web/assets/vuex-search_icons.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbertLucianto/vuex-search/HEAD/web/assets/vuex-search_icons.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | parsers: 3 | javascript: 4 | enable_partials: yes 5 | codecov: 6 | token: 3c4454bd-6f10-4030-9cee-dae98523d78d -------------------------------------------------------------------------------- /web/store/getters.js: -------------------------------------------------------------------------------- 1 | export const currentContacts = state => state.resources.contacts; 2 | export const isGenerating = state => state.resources.generating; 3 | -------------------------------------------------------------------------------- /web/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const SET_CONTACTS = 'SET_CONTACTS'; 2 | export const REMOVE_CONTACT = 'REMOVE_CONTACT'; 3 | export const SET_GENERATING = 'SET_GENERATING'; 4 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const devEnv = require('./dev.env') 4 | 5 | module.exports = merge(devEnv, { 6 | NODE_ENV: '"testing"' 7 | }) 8 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const merge = require('webpack-merge') 3 | const prodEnv = require('./prod.env') 4 | 5 | module.exports = merge(prodEnv, { 6 | NODE_ENV: '"development"' 7 | }) 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | install: 5 | - yarn install 6 | - yarn add vue vuex 7 | script: 8 | - yarn build 9 | cache: 10 | directories: 11 | - "node_modules" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | /dist/ 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | /test/unit/coverage/ 8 | /test/e2e/reports/ 9 | selenium-debug.log 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | "postcss-import": {}, 6 | "postcss-url": {}, 7 | // to edit target browsers: use "browserslist" field in package.json 8 | "autoprefixer": {} 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const SET_SEARCH_RESULT = '@vuexSearch/mutation/SET_SEARCH_RESULT'; 2 | export const SET_INIT_RESOURCE = '@vuexSearch/mutation/SET_INIT_RESOURCE'; 3 | export const SET_SEARCH = '@vuexSearch/mutation/SET_SEARCH'; 4 | export const DELETE_RESOURCE = '@vuexSearch/mutation/DELETE_RESOURCE'; 5 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es5", 8 | "dom", 9 | "es2015.promise" 10 | ], 11 | "strict": true, 12 | "noEmit": true 13 | }, 14 | "include": [ 15 | "*.d.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | build 2 | config 3 | dist/web 4 | node_modules 5 | rollup 6 | src 7 | static 8 | test 9 | web 10 | .babelrc 11 | .editorconfig 12 | .eslintignore 13 | .eslintrc.* 14 | .postcssrc.* 15 | CHANGELOG.md 16 | README.md 17 | CONTRIBUTING.md 18 | tslint.json 19 | .travis.yml 20 | codecov.yml 21 | index.html 22 | yarn.lock 23 | yarn-error.log 24 | tmp 25 | -------------------------------------------------------------------------------- /src/action-types.js: -------------------------------------------------------------------------------- 1 | export const RECEIVE_RESULT = '@@vuexSearch/action/RECEIVE_RESULT'; 2 | export const SEARCH = '@@vuexSearch/action/SEARCH'; 3 | export const searchApi = { 4 | INDEX_RESOURCE: '@@vuexSearch/action/API/INDEX_RESOURCE', 5 | DEFINE_INDEX: '@@vuexSearch/action/API/DEFINE_INDEX', 6 | PERFORM_SEARCH: '@@vuexSearch/action/API/PERFORM_SEARCH', 7 | }; 8 | -------------------------------------------------------------------------------- /src/getters.js: -------------------------------------------------------------------------------- 1 | import * as getterTypes from './getter-types'; 2 | 3 | export default { 4 | [getterTypes.resourceIndexByName]: state => resourceName => state[resourceName], 5 | [getterTypes.isSearchingByName]: state => resourceName => state[resourceName].isSearching, 6 | [getterTypes.resultByName]: state => resourceName => state[resourceName].result, 7 | }; 8 | -------------------------------------------------------------------------------- /src/getter-types.js: -------------------------------------------------------------------------------- 1 | export const resourceIndexByName = '@@vuexSearch/getter/resourceIndex'; 2 | export const isSearchingByName = '@@vuexSearch/getter/isSearching'; 3 | export const resultByName = '@@vuexSearch/getter/result'; 4 | 5 | export const api = { 6 | resourceIndex: resourceIndexByName, 7 | isSearching: isSearchingByName, 8 | result: resultByName, 9 | }; 10 | -------------------------------------------------------------------------------- /web/store/mutations.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import * as mutationTypes from './mutation-types'; 3 | 4 | export default { 5 | [mutationTypes.SET_CONTACTS](state, { contacts }) { 6 | Vue.set(state.resources, 'contacts', contacts); 7 | }, 8 | 9 | [mutationTypes.SET_GENERATING](state, { generating }) { 10 | Vue.set(state.resources, 'generating', generating); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { INDEX_MODES } from 'js-worker-search'; 2 | 3 | import plugin from './plugin'; 4 | import SearchApi from './SearchApi'; 5 | import { api as getterTypes } from './getter-types'; 6 | import mapActions from './mapActions'; 7 | import mapGetters from './mapGetters'; 8 | import VuexSearch, { publicApi as actionTypes } from './VuexSearch'; 9 | 10 | export { 11 | SearchApi, 12 | INDEX_MODES, 13 | actionTypes, 14 | getterTypes, 15 | mapActions, 16 | mapGetters, 17 | VuexSearch, 18 | }; 19 | 20 | export default plugin; 21 | -------------------------------------------------------------------------------- /test/e2e/specs/test.js: -------------------------------------------------------------------------------- 1 | // For authoring Nightwatch tests, see 2 | // http://nightwatchjs.org/guide#usage 3 | 4 | module.exports = { 5 | 'default e2e tests': function test(browser) { 6 | // automatically uses dev Server port from /config.index.js 7 | // default: http://localhost:8080 8 | // see nightwatch.conf.js 9 | const devServer = browser.globals.devServerURL; 10 | 11 | browser 12 | .url(devServer) 13 | .waitForElementVisible('#app', 5000) 14 | .assert.elementPresent('.hello') 15 | .assert.containsText('h1', 'Welcome to Your Vue.js App') 16 | .assert.elementCount('img', 1) 17 | .end(); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/plugin.js: -------------------------------------------------------------------------------- 1 | import VuexSearch from './VuexSearch'; 2 | import SubscribableSearchApi from './SearchApi'; 3 | 4 | /** 5 | * Vuex binding for client-side search with indexer and Web Workers 6 | * 7 | * @param {[resourceName: string]: { getter, indexes, searchApi? }} resources 8 | * Resource to watch and index 9 | * @param {SearchApi} searchApi Optional, can also use custom SearchApi instances 10 | */ 11 | export default function plugin({ 12 | resources = {}, 13 | searchApi = new SubscribableSearchApi(), 14 | } = {}) { 15 | return (store) => { 16 | /* eslint-disable no-new */ 17 | new VuexSearch({ store, resources, searchApi }); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /types/VuexSearch.d.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'vuex'; 2 | import { ResourceOptions } from '.'; 3 | import { SearchApi } from './SearchApi'; 4 | 5 | export declare class VuexSearch { 6 | constructor(options: VuexSearchOptions); 7 | 8 | registerResource: (resourceName: string, config: ResourceOptions) => void; 9 | search: (resourceName: string, searchString: string) => void; 10 | reindex: (resourceName: string) => void; 11 | unregisterModule: (resourceName: string) => void; 12 | } 13 | 14 | export interface VuexSearchOptions { 15 | store: Store; 16 | resources: { [resourceName: string]: ResourceOptions }; 17 | searchApi: SearchApi; 18 | } 19 | -------------------------------------------------------------------------------- /web/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue'; 4 | import TextHighlighter from 'vue-text-highlight'; 5 | import VueVirtualScroller from 'vue-virtual-scroller'; 6 | import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'; 7 | 8 | import App from './App'; 9 | import store from './store'; 10 | 11 | Vue.config.productionTip = false; 12 | Vue.use(VueVirtualScroller); 13 | Vue.component('text-highlighter', TextHighlighter); 14 | 15 | /* eslint-disable no-new */ 16 | new Vue({ 17 | el: '#app', 18 | components: { App }, 19 | store, 20 | template: '', 21 | }); 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vuex Search 7 | 8 | 9 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /types/mappers.d.ts: -------------------------------------------------------------------------------- 1 | type Dictionary = { [key: string]: T }; 2 | type Computed = () => any; 3 | type ActionMethod = (...args: any[]) => void; 4 | 5 | interface MapperWithResourceName { 6 | (resourceName: string, map: T[]): Dictionary; 7 | (resourceName: string, map: Dictionary): Dictionary; 8 | } 9 | 10 | export declare enum actionTypes { 11 | search, 12 | reindex, 13 | registerResource, 14 | unregisterResource, 15 | } 16 | 17 | export declare enum getterTypes { 18 | resourceIndex, 19 | isSearching, 20 | result, 21 | } 22 | 23 | export type mapActions = MapperWithResourceName; 24 | export type mapGetters = MapperWithResourceName; 25 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, Store } from 'vuex'; 2 | import { SearchApi, Resource } from './SearchApi'; 3 | 4 | interface WatchOptions { 5 | delay: number; 6 | } 7 | 8 | export interface ResourceOptions { 9 | index: string[]; 10 | getter: (state: S) => any; 11 | watch: boolean|WatchOptions; 12 | searchApi?: SearchApi; 13 | } 14 | 15 | export interface PluginOptions { 16 | resources: { [resourceName: string]: ResourceOptions }; 17 | searchApi?: SearchApi; 18 | } 19 | 20 | declare function vuexSearchPlugin(options: PluginOptions): Plugin; 21 | 22 | export * from './mappers'; 23 | export * from './SearchApi'; 24 | export * from './VuexSearch'; 25 | 26 | export default vuexSearchPlugin; 27 | -------------------------------------------------------------------------------- /web/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import vuexSearch from 'vuex-search'; 4 | 5 | import * as getters from './getters'; 6 | import actions from './actions'; 7 | import mutations from './mutations'; 8 | 9 | Vue.use(Vuex); 10 | 11 | const initialState = { 12 | resources: { contacts: [], generating: false }, 13 | }; 14 | 15 | export default new Vuex.Store({ 16 | getters, 17 | actions, 18 | mutations, 19 | state: initialState, 20 | plugins: [ 21 | vuexSearch({ 22 | resources: { 23 | contacts: { 24 | index: ['address', 'name', 'words'], 25 | getter: state => state.resources.contacts, 26 | }, 27 | }, 28 | }), 29 | ], 30 | }); 31 | -------------------------------------------------------------------------------- /src/mapGetters.js: -------------------------------------------------------------------------------- 1 | import { normalizeMap, normalizeNamespaceName } from './utils'; 2 | 3 | /** 4 | * Generate getters with injected resourceName for simpler api. 5 | * 6 | * @param {String} [resourceName] Unique resource identifier defined in the plugin. 7 | * @param {Object|Array} actions Object mapping from intented method name to getterType; 8 | * or an array of getterTypes. 9 | * @return {Object} 10 | */ 11 | export default (resourceName, getters) => { 12 | const res = {}; 13 | normalizeMap(getters).forEach(({ key, val }) => { 14 | res[key] = function mappedGetter() { 15 | const namespace = normalizeNamespaceName(this.$store.search._base); 16 | return this.$store.getters[`${namespace}${val}`](resourceName); 17 | }; 18 | }); 19 | 20 | return res; 21 | }; 22 | -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // The assertion name is the filename. 3 | // Example usage: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // For more information on custom assertions see: 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | 10 | exports.assertion = function (selector, count) { 11 | this.message = 'Testing if element <' + selector + '> has count: ' + count 12 | this.expected = count 13 | this.pass = function (val) { 14 | return val === this.expected 15 | } 16 | this.value = function (res) { 17 | return res.value 18 | } 19 | this.command = function (cb) { 20 | var self = this 21 | return this.api.execute(function (selector) { 22 | return document.querySelectorAll(selector).length 23 | }, [selector], function (res) { 24 | cb.call(self, res) 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/unit/jest.conf.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | rootDir: path.resolve(__dirname, '../../'), 5 | moduleFileExtensions: [ 6 | 'js', 7 | 'json', 8 | 'vue', 9 | ], 10 | moduleNameMapper: { 11 | '^vuex-search/(.*)$': '/src/$1', 12 | 'vuex-search': '/src/index', 13 | }, 14 | transform: { 15 | '^.+\\.js$': '/node_modules/babel-jest', 16 | '.*\\.(vue)$': '/node_modules/vue-jest', 17 | }, 18 | testPathIgnorePatterns: [ 19 | '/test/e2e', 20 | ], 21 | snapshotSerializers: ['/node_modules/jest-serializer-vue'], 22 | setupFiles: ['/test/unit/setup'], 23 | coverageDirectory: '/test/unit/coverage', 24 | collectCoverageFrom: [ 25 | 'src/**/*.{js,vue}', 26 | '!src/main.js', 27 | '!**/node_modules/**', 28 | ], 29 | testURL: 'http://localhost/', 30 | }; 31 | -------------------------------------------------------------------------------- /src/mutations.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import * as mutationTypes from './mutation-types'; 3 | 4 | export default { 5 | [mutationTypes.SET_INIT_RESOURCE]: (state, { resourceName }) => { 6 | Vue.set(state, resourceName, { 7 | isSearching: false, 8 | text: '', 9 | result: [], 10 | }); 11 | }, 12 | 13 | [mutationTypes.SET_SEARCH_RESULT]: (state, { 14 | resourceName, 15 | result, 16 | text, 17 | }) => { 18 | Vue.set(state, resourceName, { 19 | isSearching: false, 20 | text, 21 | result, 22 | }); 23 | }, 24 | 25 | [mutationTypes.SET_SEARCH]: (state, { resourceName, searchString }) => { 26 | Vue.set(state[resourceName], 'text', searchString); 27 | Vue.set(state[resourceName], 'isSearching', true); 28 | }, 29 | 30 | [mutationTypes.DELETE_RESOURCE]: (state, { resourceName }) => { 31 | Vue.delete(state, resourceName); 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /web/worker/generate-data.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker'; 2 | 3 | function execFromPath(object, paths, args = []) { 4 | return paths.reduce((acc, path) => acc[path], object)(...args); 5 | } 6 | 7 | self.addEventListener( 8 | 'message', 9 | (e) => { 10 | const { data } = e; 11 | const { quantity, getData } = data; 12 | 13 | const results = {}; 14 | 15 | new Array(quantity).fill(null).forEach(() => { 16 | const id = faker.random.uuid(); 17 | const result = { id }; 18 | 19 | Object.entries(getData).forEach(([field, fnPath]) => { 20 | if (Array.isArray(fnPath)) { 21 | result[field] = execFromPath(faker, fnPath); 22 | } else { 23 | const { path, args } = fnPath; 24 | result[field] = execFromPath(faker, path, args); 25 | } 26 | }); 27 | 28 | results[id] = result; 29 | }); 30 | 31 | self.postMessage(results); 32 | }, 33 | false, 34 | ); 35 | -------------------------------------------------------------------------------- /src/mapActions.js: -------------------------------------------------------------------------------- 1 | import { normalizeMap } from './utils'; 2 | import { publicApi } from './VuexSearch'; 3 | 4 | /** 5 | * Generate actions with transformed payload for simpler api. 6 | * 7 | * @param {String} resourceName Unique resource identifier defined in the plugin. 8 | * @param {Object|Array} actions Object mapping from intented method name to actionType; 9 | * or an array of actionTypes. 10 | * @return {Object} 11 | */ 12 | export default (resourceName, actions) => { 13 | const res = {}; 14 | const publicApiTypes = Object.values(publicApi); 15 | 16 | normalizeMap(actions).forEach(({ key, val }) => { 17 | if (!publicApiTypes.includes(val) && process.env.NODE_ENV !== 'production') { 18 | throw new Error(`unknown actionType '${val}' is passed to mapActions`); 19 | } 20 | 21 | res[key] = function mappedAction(...args) { 22 | const vuexSearch = this.$store.search; 23 | 24 | return vuexSearch[val](resourceName, ...args); 25 | }; 26 | }); 27 | 28 | return res; 29 | }; 30 | -------------------------------------------------------------------------------- /web/components/StyledButton.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | 24 | 52 | -------------------------------------------------------------------------------- /test/unit/specs/mapGetters.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import mapGetters from 'vuex-search/mapGetters'; 4 | 5 | Vue.use(Vuex); 6 | 7 | describe('mapGetters', () => { 8 | test('should return basic getter with resourceName', () => { 9 | const base = 'test'; 10 | const store = new Vuex.Store({}); 11 | store.search = { _base: base }; 12 | 13 | store.registerModule(base, { 14 | namespaced: true, 15 | state: { 16 | test: { count: 0 }, 17 | }, 18 | mutations: { 19 | inc: (state) => { 20 | state.test.count += 1; 21 | }, 22 | }, 23 | getters: { 24 | count: state => resourceName => state[resourceName].count, 25 | }, 26 | }); 27 | 28 | const resourceName = 'test'; 29 | const getterMap = { number: 'count' }; 30 | 31 | const vm = new Vue({ 32 | store, 33 | computed: mapGetters(resourceName, getterMap), 34 | }); 35 | 36 | expect(vm.number).toEqual(0); 37 | 38 | store.commit(`${base}/inc`); 39 | expect(vm.number).toEqual(1); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /test/unit/specs/plugin.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import plugin, { VuexSearch } from 'vuex-search'; 4 | 5 | Vue.use(Vuex); 6 | 7 | function getStoreWithPlugins(plugins) { 8 | const documentA = { id: 1, name: 'One', description: 'The first document' }; 9 | const documentB = { id: 2, name: 'Two', description: 'The second document' }; 10 | const documentC = { id: 3, name: 'Three', description: 'The third document' }; 11 | const documentD = { id: 4, name: 'Four', description: 'The 4th (fourth) document' }; 12 | 13 | const initialState = { 14 | resources: { 15 | documents: [documentA, documentB, documentC, documentD], 16 | }, 17 | }; 18 | 19 | return new Vuex.Store({ 20 | state: initialState, 21 | plugins, 22 | }); 23 | } 24 | 25 | describe('plugin', () => { 26 | test('should instantiate VuexSearch and accessible through store.search', () => { 27 | const plugins = [ 28 | plugin(), 29 | ]; 30 | 31 | const store = getStoreWithPlugins(plugins); 32 | 33 | expect(store.search instanceof VuexSearch).toBe(true); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Albert Lucianto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": [ 12 | "transform-vue-jsx", 13 | "transform-runtime" 14 | ], 15 | "env": { 16 | "test": { 17 | "presets": [ 18 | "env", 19 | "stage-2" 20 | ], 21 | "plugins": [ 22 | "transform-vue-jsx", 23 | "transform-es2015-modules-commonjs", 24 | "dynamic-import-node" 25 | ] 26 | }, 27 | "commonjs": { 28 | "presets": [ 29 | "es2015", 30 | "stage-2" 31 | ], 32 | "plugins": [ 33 | "transform-vue-jsx", 34 | "transform-es2015-modules-commonjs", 35 | "dynamic-import-node", 36 | "transform-runtime" 37 | ] 38 | }, 39 | "es": { 40 | "presets": [ 41 | "es2015-rollup", 42 | "stage-2" 43 | ], 44 | "plugins": [ 45 | "transform-vue-jsx", 46 | "transform-es2015-modules-commonjs", 47 | "dynamic-import-node", 48 | "transform-runtime" 49 | ] 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | require('babel-register') 2 | var config = require('../../config') 3 | 4 | // http://nightwatchjs.org/gettingstarted#settings-file 5 | module.exports = { 6 | src_folders: ['test/e2e/specs'], 7 | output_folder: 'test/e2e/reports', 8 | custom_assertions_path: ['test/e2e/custom-assertions'], 9 | 10 | selenium: { 11 | start_process: true, 12 | server_path: require('selenium-server').path, 13 | host: '127.0.0.1', 14 | port: 4444, 15 | cli_args: { 16 | 'webdriver.chrome.driver': require('chromedriver').path 17 | } 18 | }, 19 | 20 | test_settings: { 21 | default: { 22 | selenium_port: 4444, 23 | selenium_host: 'localhost', 24 | silent: true, 25 | globals: { 26 | devServerURL: 'http://localhost:' + (process.env.PORT || config.dev.port) 27 | } 28 | }, 29 | 30 | chrome: { 31 | desiredCapabilities: { 32 | browserName: 'chrome', 33 | javascriptEnabled: true, 34 | acceptSslCerts: true 35 | } 36 | }, 37 | 38 | firefox: { 39 | desiredCapabilities: { 40 | browserName: 'firefox', 41 | javascriptEnabled: true, 42 | acceptSslCerts: true 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/unit/specs/mapActions.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import mapActions from 'vuex-search/mapActions'; 4 | import { publicApi } from 'vuex-search/VuexSearch'; 5 | 6 | Vue.use(Vuex); 7 | 8 | describe('mapActions', () => { 9 | test('should map actions, call VuexSearch, and dispatch action', () => { 10 | const base = 'test'; 11 | const search = jest.fn(); 12 | const store = new Vuex.Store({}); 13 | const mockedVuexSearch = { 14 | _base: base, 15 | search, 16 | }; 17 | 18 | store.search = mockedVuexSearch; 19 | 20 | const resourceName = 'test'; 21 | const actionMap = { 22 | method: publicApi.search, 23 | }; 24 | 25 | const vm = new Vue({ 26 | store, 27 | methods: mapActions(resourceName, actionMap), 28 | }); 29 | 30 | vm.method('word'); 31 | expect(search).toBeCalledWith( 32 | resourceName, 33 | 'word', 34 | ); 35 | }); 36 | 37 | test('should throw when unknown actionType is mapped', () => { 38 | const resourceName = 'test'; 39 | const actionMap = { 40 | method: 'someUnknownMethod', 41 | }; 42 | 43 | expect(() => mapActions(resourceName, actionMap)).toThrow('unknown'); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the project 2 | 3 | ## Forking the repo 4 | 1. Make sure you're logged into your GitHub account! 5 | 2. Access the repo at [https://github.com/albertlucianto/vuex-search](https://github.com/albertlucianto/vuex-search). 6 | 3. Hit the 'fork' button in the top right corner. 7 | 4. Hack away in your local version! 8 | 9 | ## Cloning the repo 10 | 1. Make sure you have [git](https://git-scm.com/) installed! 11 | 2. Access the repo at [https://github.com/albertlucianto/vuex-search](https://github.com/albertlucianto/vuex-search). 12 | 3. Press the big green 'clone or download' button and copy that URL. 13 | 4. Open your favorite terminal, and execute the command `git clone https://github.com/albertlucianto/vuex-search`. 14 | 15 | ## Fixing a bug 16 | 1. Add your local changes after fixing a bug with `git add .` to add the files. 17 | 2. Commit your local changes with`git commit -m "[TOPIC] {description}"`. 18 | 3. Push by running `git push origin master` to master or `git push {branch}` for a branch. 19 | 20 | ## Sending a pull request 21 | 1. Head to your main page of your forked repo. 22 | 2. Hit the create a pull request button. 23 | 3. Write a good description of the bug you fixed, and whatever details you think are necessary. 24 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import * as mutationTypes from './mutation-types'; 2 | import * as actionTypes from './action-types'; 3 | 4 | /** 5 | * Actions with injected search API mapper. 6 | * 7 | * @param {{ [resourceName: string]: Search }} searchMap mapper of each resources searchApi 8 | */ 9 | export default function actionsWithSearch(searchMap) { 10 | return { 11 | [actionTypes.RECEIVE_RESULT]({ commit }, { resourceName, result, text }) { 12 | commit(mutationTypes.SET_SEARCH_RESULT, { 13 | resourceName, 14 | result, 15 | text, 16 | }); 17 | }, 18 | 19 | [actionTypes.searchApi.INDEX_RESOURCE](_, params) { 20 | const { resourceName } = params; 21 | searchMap[resourceName].indexResource(params); 22 | }, 23 | 24 | [actionTypes.searchApi.PERFORM_SEARCH](_, { resourceName, searchString }) { 25 | searchMap[resourceName].stopSearch(resourceName); 26 | searchMap[resourceName].performSearch(resourceName, searchString); 27 | }, 28 | 29 | [actionTypes.SEARCH]({ commit, dispatch }, { resourceName, searchString }) { 30 | commit(mutationTypes.SET_SEARCH, { resourceName, searchString }); 31 | dispatch(actionTypes.searchApi.PERFORM_SEARCH, { resourceName, searchString }); 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /test/unit/specs/getters.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import getters from 'vuex-search/getters'; 4 | import * as getterTypes from 'vuex-search/getter-types'; 5 | 6 | Vue.use(Vuex); 7 | 8 | function getStore() { 9 | const initialState = { 10 | test: { 11 | result: [], 12 | isSearching: false, 13 | text: '', 14 | }, 15 | }; 16 | 17 | return new Vuex.Store({ 18 | getters, 19 | state: initialState, 20 | }); 21 | } 22 | 23 | describe('getters', () => { 24 | test('should get resource details by resourceName', () => { 25 | const store = getStore(); 26 | const resource = store.getters[getterTypes.resourceIndexByName]('test'); 27 | 28 | expect(resource).toEqual({ 29 | result: [], 30 | isSearching: false, 31 | text: '', 32 | }); 33 | }); 34 | 35 | test('should get isSearching by resourceName', () => { 36 | const store = getStore(); 37 | const isSearching = store.getters[getterTypes.isSearchingByName]('test'); 38 | 39 | expect(isSearching).toEqual(false); 40 | }); 41 | 42 | test('should get isSearching by resourceName', () => { 43 | const store = getStore(); 44 | const result = store.getters[getterTypes.resultByName]('test'); 45 | 46 | expect(result).toEqual([]); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /web/assets/loading-cubes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /types/SearchApi.d.ts: -------------------------------------------------------------------------------- 1 | export declare class SearchApi { 2 | constructor(options: SearchApiOptions); 3 | 4 | subscribe(onNext: (searchResult: SearchResult) => any, onError: (message: any) => any): () => void; 5 | indexResource(options: IndexResourceOptions): void; 6 | performSearch: (resourceName: string, text: string) => Promise; 7 | stopSearch: (resourceName: string) => void; 8 | } 9 | 10 | export declare enum INDEX_MODES { 11 | PREFIXES, 12 | EXACT_WORDS, 13 | } 14 | 15 | export interface SearchApiOptions { 16 | indexMode?: INDEX_MODES; 17 | tokenizePattern?: RegExp; 18 | caseSensitive?: boolean; 19 | } 20 | 21 | export interface SearchResult { 22 | result: string[]; 23 | text: string; 24 | resourceName: string; 25 | } 26 | 27 | export interface IndexResourceOptions { 28 | fieldNamesOrIndexFunction: string[] | IndexFunction; 29 | resourceName: string; 30 | resources: Resources; 31 | state?: any; 32 | } 33 | 34 | export type IndexFunction = (arg: IndexFunctionOptions) => any; 35 | export interface IndexFunctionOptions { 36 | indexDocument: (uid: string, text: string) => void; 37 | resources: Resources; 38 | state: any; 39 | } 40 | 41 | export type Resources = { [key: string]: R } | R[]; 42 | export interface Resource { 43 | readonly id: string; 44 | } 45 | -------------------------------------------------------------------------------- /web/components/InputField.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | 37 | 65 | -------------------------------------------------------------------------------- /web/store/actions.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker'; 2 | import * as mutationTypes from './mutation-types'; 3 | 4 | // eslint-disable-next-line 5 | const GenerateDataWorker = require('worker-loader?inline=true!../worker/generate-data.js'); 6 | const gdWorker = Worker ? new GenerateDataWorker() : null; 7 | export default { 8 | fetchContacts({ commit }, { quantity = 1000 } = {}) { 9 | if (gdWorker) { 10 | gdWorker.postMessage({ 11 | quantity, 12 | getData: { // Use path because Web Worker only supports serializable object 13 | name: ['name', 'findName'], 14 | address: ['address', 'streetAddress'], 15 | avatar: ['image', 'avatar'], 16 | words: { path: ['random', 'words'], args: [10] }, 17 | }, 18 | }); 19 | commit(mutationTypes.SET_GENERATING, { generating: true }); 20 | 21 | gdWorker.onmessage = (e) => { 22 | const contacts = e.data; 23 | commit(mutationTypes.SET_CONTACTS, { contacts }); 24 | commit(mutationTypes.SET_GENERATING, { generating: false }); 25 | }; 26 | } else { 27 | const contacts = {}; 28 | 29 | new Array(quantity).fill(null).forEach(() => { 30 | const id = faker.random.uuid(); 31 | contacts[id] = { 32 | id, 33 | name: faker.name.findName(), 34 | address: faker.address.streetAddress(), 35 | avatar: faker.image.avatar(), 36 | words: faker.random.words(10), 37 | }; 38 | }); 39 | 40 | commit(mutationTypes.SET_CONTACTS, { contacts }); 41 | } 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parserOptions: { 6 | parser: 'babel-eslint' 7 | }, 8 | env: { 9 | browser: true, 10 | }, 11 | // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention 12 | // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules. 13 | extends: ['plugin:vue/essential', 'airbnb-base'], 14 | // required to lint *.vue files 15 | plugins: [ 16 | 'vue' 17 | ], 18 | // check if imports actually resolve 19 | settings: { 20 | 'import/resolver': { 21 | webpack: { 22 | config: 'build/webpack.base.conf.js' 23 | } 24 | } 25 | }, 26 | // add your custom rules here 27 | rules: { 28 | // don't require .vue extension when importing 29 | 'import/extensions': ['error', 'always', { 30 | js: 'never', 31 | vue: 'never' 32 | }], 33 | // disallow reassignment of function parameters 34 | // disallow parameter object manipulation except for specific exclusions 35 | 'no-param-reassign': ['error', { 36 | props: true, 37 | ignorePropertyModificationsFor: [ 38 | 'state', // for vuex state 39 | 'acc', // for reduce accumulators 40 | 'e' // for e.returnvalue 41 | ] 42 | }], 43 | // allow optionalDependencies 44 | 'import/no-extraneous-dependencies': ['error', { 45 | optionalDependencies: ['test/unit/index.js'] 46 | }], 47 | // allow debugger during development 48 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 49 | 'no-underscore-dangle': 'off' 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | 4 | const webpack = require('webpack') 5 | const DevServer = require('webpack-dev-server') 6 | 7 | const webpackConfig = require('../../build/webpack.prod.conf') 8 | const devConfigPromise = require('../../build/webpack.dev.conf') 9 | 10 | let server 11 | 12 | devConfigPromise.then(devConfig => { 13 | const devServerOptions = devConfig.devServer 14 | const compiler = webpack(webpackConfig) 15 | server = new DevServer(compiler, devServerOptions) 16 | const port = devServerOptions.port 17 | const host = devServerOptions.host 18 | return server.listen(port, host) 19 | }) 20 | .then(() => { 21 | // 2. run the nightwatch test suite against it 22 | // to run in additional browsers: 23 | // 1. add an entry in test/e2e/nightwatch.conf.js under "test_settings" 24 | // 2. add it to the --env flag below 25 | // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` 26 | // For more information on Nightwatch's config file, see 27 | // http://nightwatchjs.org/guide#settings-file 28 | let opts = process.argv.slice(2) 29 | if (opts.indexOf('--config') === -1) { 30 | opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) 31 | } 32 | if (opts.indexOf('--env') === -1) { 33 | opts = opts.concat(['--env', 'chrome']) 34 | } 35 | 36 | const spawn = require('cross-spawn') 37 | const runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 38 | 39 | runner.on('exit', function (code) { 40 | server.close() 41 | process.exit(code) 42 | }) 43 | 44 | runner.on('error', function (err) { 45 | server.close() 46 | throw err 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /web/components/ContactDetail.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 36 | 37 | 72 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Normalize the map 3 | * normalizeMap([1, 2]) => [ { key: 1, val: 1 }, { key: 2, val: 2 } ] 4 | * normalizeMap({a: 1, b: 2}) => [ { key: 'a', val: 1 }, { key: 'b', val: 2 } ] 5 | * @param {Array|Object} map 6 | * @return {Object} 7 | */ 8 | export function normalizeMap(map) { 9 | return Array.isArray(map) 10 | ? map.map(key => ({ key, val: key })) 11 | : Object.keys(map).map(key => ({ key, val: map[key] })); 12 | } 13 | 14 | /** 15 | * Adds '/' 16 | * 17 | * @param {string} namespace 18 | */ 19 | export function normalizeNamespaceName(namespace) { 20 | if (namespace === '') return ''; 21 | return namespace.slice(-1) === '/' ? namespace : namespace.concat('/'); 22 | } 23 | 24 | /** 25 | * With assumption Vuex Search module starts from root. 26 | * 27 | * @param {string | [string]} modulePath 28 | */ 29 | export function modulePathToNamespace(modulePath) { 30 | if (Array.isArray(modulePath)) { 31 | return modulePath.reduce((ns, path) => (path ? `${ns}${path}/` : ns), ''); 32 | } else if (typeof modulePath === 'string') { 33 | return normalizeNamespaceName(modulePath); 34 | } 35 | return normalizeNamespaceName(JSON.stringify(modulePath)); 36 | } 37 | 38 | export const cancellationSymbol = Symbol('cancel'); 39 | 40 | /** 41 | * Basic Promise does not support promise cancellation. 42 | * This function wraps the basic promise and returns cancellable one. 43 | * 44 | * @param {Promise} promise 45 | */ 46 | export function cancellablePromiseWrapper(promise) { 47 | let rej; 48 | 49 | const wrappedPromise = new Promise(async (resolve, reject) => { 50 | rej = reject; 51 | 52 | try { 53 | const res = await promise; 54 | resolve(res); 55 | } catch (e) { 56 | reject(e); 57 | } 58 | }); 59 | 60 | wrappedPromise.cancel = () => rej(cancellationSymbol); 61 | 62 | return wrappedPromise; 63 | } 64 | 65 | /** 66 | * Postpone its execution until after wait milliseconds 67 | * have elapsed since the last time it was invoked. 68 | * 69 | * @param {Function} fn Function callback after delay 70 | * @param {Number} delay Debounce time 71 | */ 72 | export function debounce(fn, delay = 0) { 73 | let timeoutId; 74 | 75 | if (delay === 0) return fn; 76 | 77 | return (...args) => { 78 | if (timeoutId) clearTimeout(timeoutId); 79 | timeoutId = setTimeout(() => fn(...args), delay); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /web/App.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 39 | 40 | 91 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | // Template version: 1.3.1 3 | // see http://vuejs-templates.github.io/webpack for documentation. 4 | 5 | const path = require('path') 6 | 7 | module.exports = { 8 | dev: { 9 | 10 | // Paths 11 | assetsSubDirectory: 'static', 12 | assetsPublicPath: '/', 13 | proxyTable: {}, 14 | 15 | // Various Dev Server settings 16 | host: 'localhost', // can be overwritten by process.env.HOST 17 | port: 8080, // can be overwritten by process.env.PORT, if port is in use, a free one will be determined 18 | autoOpenBrowser: false, 19 | errorOverlay: true, 20 | notifyOnErrors: true, 21 | poll: false, // https://webpack.js.org/configuration/dev-server/#devserver-watchoptions- 22 | 23 | // Use Eslint Loader? 24 | // If true, your code will be linted during bundling and 25 | // linting errors and warnings will be shown in the console. 26 | useEslint: true, 27 | // If true, eslint errors and warnings will also be shown in the error overlay 28 | // in the browser. 29 | showEslintErrorsInOverlay: false, 30 | 31 | /** 32 | * Source Maps 33 | */ 34 | 35 | // https://webpack.js.org/configuration/devtool/#development 36 | devtool: 'cheap-module-eval-source-map', 37 | 38 | // If you have problems debugging vue-files in devtools, 39 | // set this to false - it *may* help 40 | // https://vue-loader.vuejs.org/en/options.html#cachebusting 41 | cacheBusting: true, 42 | 43 | cssSourceMap: true 44 | }, 45 | 46 | build: { 47 | // Template for index.html 48 | index: path.resolve(__dirname, '../dist/web/index.html'), 49 | 50 | // Paths 51 | assetsRoot: path.resolve(__dirname, '../dist/web'), 52 | assetsSubDirectory: 'static', 53 | assetsPublicPath: '/vuex-search/', 54 | 55 | /** 56 | * Source Maps 57 | */ 58 | 59 | productionSourceMap: true, 60 | // https://webpack.js.org/configuration/devtool/#production 61 | devtool: '#source-map', 62 | 63 | // Gzip off by default as many popular static hosts such as 64 | // Surge or Netlify already gzip all static assets for you. 65 | // Before setting to `true`, make sure to: 66 | // npm install --save-dev compression-webpack-plugin 67 | productionGzip: false, 68 | productionGzipExtensions: ['js', 'css'], 69 | 70 | // Run the build command with an extra argument to 71 | // View the bundle analyzer report after build finishes: 72 | // `npm run build --report` 73 | // Set to `true` or `false` to always turn it on or off 74 | bundleAnalyzerReport: process.env.npm_config_report 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /web/components/ListContacts.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 91 | 92 | 134 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | # 2.2.0 4 | 5 | _No breaking changes in this update._ 6 | 7 | ## Added Watch Options 8 | 9 | * `[watch]: boolean|WatchOptions` 10 | 11 | Now reindex on resource changes can be debounced. 12 | 13 | __`WatchOptions`__ 14 | 15 | * __`[delay]:`__ `number` 16 | 17 | If provided, reindex will be debounced with specified delay. 18 | 19 | # 2.1.0 20 | 21 | _No breaking changes in this update._ 22 | 23 | ## Added new resource index option 24 | 25 | * `[watch]: boolean` 26 | 27 | Whether needs to reindex if resource changes. This option is useful to avoid reindex overhead when the resource frequently changes. 28 | 29 | Default: `true` 30 | 31 | __Example:__ 32 | 33 | ```javascript 34 | searchPlugin({ 35 | resources: { 36 | contacts: { 37 | index: ['address', 'name'], 38 | getter: state => state.myResources.contacts, 39 | // Do not reindex automatically if resource changes 40 | watch: false, 41 | }, 42 | }, 43 | }), 44 | ``` 45 | 46 | ## Added more `actionTypes` 47 | 48 | * `reindex` 49 | 50 | Mapped action has signature: `() => void`. To be used when option `watch` is `false`. This action will reindex the resource and automatically re-`search` current search `text`. 51 | 52 | * `registerResource` 53 | 54 | Mapped action has signature: `(options: IndexOptions) => void`. This action will dynamically add `resourceName` with options provided. 55 | 56 | * `unregisterResource` 57 | 58 | Mapped action has signature: `() => void`. This action will unwatch and remove `resourceName` index. 59 | 60 | # 2.0.0 61 | 62 | ## Option schema changes for defining plugin 63 | 64 | __In v1.x:__ 65 | 66 | Vuex can use multiple named plugins, where each plugin defines one `resourceGetter` and one `searchApi` to be shared by `resourceIndexes` in the plugin. Thus another plugin must be defined if different `searchApi` is to be used. 67 | 68 | ```js 69 | searchPlugin({ 70 | name: 'myIndex', 71 | resourceIndexes: { 72 | contacts: ['address', 'name'], 73 | }, 74 | resourceGetter: (resourceName, state) => state.myResources[resourceName], 75 | searchApi: new SearchApi(); 76 | }); 77 | ``` 78 | 79 | This sturcture may be confusing and redundant, since resource's name itself can already be a unique identifier for an index without plugin's name. 80 | 81 | Also, this API makes impossible to support dynamic index registration. 82 | 83 | __In v2.0:__ 84 | 85 | Similar to v1, custom `searchApi` can be defined in plugin option. However, now each resource has its own `getter` and optional `searchApi`. 86 | 87 | ```js 88 | searchPlugin({ 89 | resources: { 90 | contacts: { 91 | // what fields to index 92 | index: ['address', 'name'], 93 | // access the state to be watched by Vuex Search 94 | getter: state => state.myResources.contacts, 95 | }, 96 | }, 97 | }), 98 | ``` 99 | 100 | vuex-search v2 also supports dynamic index registration. 101 | 102 | Vuex Search can be accessed through `store.search` or `this.$store.search` in a Vue instance. 103 | 104 | ## Mappers 105 | 106 | __In v1.x:__ 107 | 108 | Because plugin can be named, `mapActions` and `mapGetters` needs to be composed from `composeSearchMappers` like so. 109 | 110 | ```js 111 | import { composeSearchMappers } from 'vuex-search'; 112 | 113 | // Composing actions and getters from selected plugin name 114 | const { mapSearchGetters, mapSearchActions } = composeSearchMappers('myIndex'); 115 | ``` 116 | 117 | __In v2.0:__ 118 | 119 | Plugin name option is removed. Now mappers needn't to be composed. 120 | 121 | ```js 122 | import { 123 | mapActions as mapSearchActions, 124 | mapGetters as mapSearchGetters, 125 | } from 'vuex-search'; 126 | ``` 127 | 128 | # 1.0.0 129 | 130 | Initial release. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vuex-search", 3 | "version": "2.2.5", 4 | "description": "Vuex binding for client-side search with indexers and Web Workers", 5 | "author": "Albert Lucianto", 6 | "main": "dist/commonjs/index.js", 7 | "module": "dist/es/index.js", 8 | "typings": "types/index.d.ts", 9 | "scripts": { 10 | "build": "npm run build:commonjs && npm run build:es && npm run build:umd", 11 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 12 | "start": "npm run dev", 13 | "unit": "jest --config test/unit/jest.conf.js --coverage", 14 | "e2e": "node test/e2e/runner.js", 15 | "test": "npm run unit && npm run e2e", 16 | "lint": "eslint --ext .js,.vue src web test/unit test/e2e/specs", 17 | "clean:commonjs": "rimraf dist/commonjs", 18 | "clean:es": "rimraf dist/es", 19 | "clean:umd": "rimraf dist/umd", 20 | "build:commonjs": "npm run clean:commonjs && cross-env NODE_ENV=production cross-env BABEL_ENV=commonjs babel src --out-dir dist/commonjs --ignore *.test.js", 21 | "build:es": "npm run clean:es && cross-env NODE_ENV=production cross-env BABEL_ENV=es babel src --out-dir dist/es --ignore *.test.js", 22 | "build:umd": "npm run clean:umd && cross-env NODE_ENV=production webpack --config build/webpack.umd.conf.js --bail", 23 | "build:web": "node build/build.js", 24 | "coverage": "codecov -f test/unit/coverage/coverage-final.json" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/AlbertLucianto/vuex-search" 29 | }, 30 | "keywords": [ 31 | "vue", 32 | "vuex", 33 | "search" 34 | ], 35 | "license": "MIT", 36 | "files": [ 37 | "dist", 38 | "types" 39 | ], 40 | "dependencies": { 41 | "js-worker-search": "^1.2.1" 42 | }, 43 | "devDependencies": { 44 | "autoprefixer": "^7.1.2", 45 | "babel-cli": "^6.26.0", 46 | "babel-core": "^6.22.1", 47 | "babel-eslint": "^8.2.1", 48 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 49 | "babel-jest": "^21.0.2", 50 | "babel-loader": "^7.1.1", 51 | "babel-plugin-dynamic-import-node": "^1.2.0", 52 | "babel-plugin-syntax-jsx": "^6.18.0", 53 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 54 | "babel-plugin-transform-runtime": "^6.22.0", 55 | "babel-plugin-transform-vue-jsx": "^3.5.0", 56 | "babel-preset-env": "^1.3.2", 57 | "babel-preset-es2015": "^6.24.1", 58 | "babel-preset-es2015-rollup": "^3.0.0", 59 | "babel-preset-stage-2": "^6.22.0", 60 | "babel-register": "^6.22.0", 61 | "chalk": "^2.0.1", 62 | "chromedriver": "^2.27.2", 63 | "codecov": "^3.0.2", 64 | "copy-webpack-plugin": "^4.0.1", 65 | "cross-env": "^5.1.4", 66 | "cross-spawn": "^5.0.1", 67 | "css-loader": "^0.28.0", 68 | "eslint": "^4.15.0", 69 | "eslint-config-airbnb-base": "^11.3.0", 70 | "eslint-friendly-formatter": "^3.0.0", 71 | "eslint-import-resolver-webpack": "^0.8.3", 72 | "eslint-loader": "^1.7.1", 73 | "eslint-plugin-import": "^2.7.0", 74 | "eslint-plugin-vue": "^4.0.0", 75 | "extract-text-webpack-plugin": "^3.0.0", 76 | "faker": "^4.1.0", 77 | "file-loader": "^1.1.11", 78 | "friendly-errors-webpack-plugin": "^1.6.1", 79 | "html-webpack-plugin": "^2.30.1", 80 | "jest": "^22.0.4", 81 | "jest-serializer-vue": "^0.3.0", 82 | "loading-svg": "^1.0.0", 83 | "nightwatch": "^0.9.12", 84 | "node-notifier": "^5.1.2", 85 | "node-sass": "^4.8.3", 86 | "optimize-css-assets-webpack-plugin": "^3.2.0", 87 | "ora": "^1.2.0", 88 | "portfinder": "^1.0.13", 89 | "postcss-import": "^11.0.0", 90 | "postcss-loader": "^2.0.8", 91 | "postcss-url": "^7.2.1", 92 | "rimraf": "^2.6.0", 93 | "sass-loader": "^6.0.7", 94 | "selenium-server": "^3.0.1", 95 | "semver": "^5.3.0", 96 | "shelljs": "^0.7.6", 97 | "typescript": "^2.8.1", 98 | "uglifyjs-webpack-plugin": "^1.1.1", 99 | "url-loader": "^0.5.8", 100 | "vue": "2.5.22", 101 | "vue-jest": "^1.0.2", 102 | "vue-loader": "^13.3.0", 103 | "vue-style-loader": "^3.0.1", 104 | "vue-template-compiler": "2.5.22", 105 | "vue-text-highlight": "^2.0.10", 106 | "vue-virtual-scroller": "^0.11.7", 107 | "vuex": "^3.0.1", 108 | "webpack": "^3.6.0", 109 | "webpack-bundle-analyzer": "^2.9.0", 110 | "webpack-dev-server": "^2.9.1", 111 | "webpack-merge": "^4.1.0", 112 | "worker-loader": "^1.1.1" 113 | }, 114 | "engines": { 115 | "node": ">= 6.0.0", 116 | "npm": ">= 3.0.0" 117 | }, 118 | "browserslist": [ 119 | "> 1%", 120 | "last 2 versions", 121 | "not ie <= 8" 122 | ], 123 | "peerDependencies": { 124 | "vue": "2.*", 125 | "vuex": "3.*" 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /test/unit/specs/utils.spec.js: -------------------------------------------------------------------------------- 1 | import { 2 | normalizeMap, 3 | normalizeNamespaceName, 4 | modulePathToNamespace, 5 | cancellablePromiseWrapper, 6 | debounce, 7 | cancellationSymbol, 8 | } from 'vuex-search/utils'; 9 | 10 | describe('normalizeMap', () => { 11 | test('should convert object to array of key value', () => { 12 | const map = { 13 | keyOne: 'valueOne', 14 | keyTwo: 'valueTwo', 15 | }; 16 | 17 | const normalizedMap = normalizeMap(map); 18 | 19 | expect(normalizedMap).toEqual([ 20 | { key: 'keyOne', val: 'valueOne' }, 21 | { key: 'keyTwo', val: 'valueTwo' }, 22 | ]); 23 | }); 24 | 25 | test('should convert array of strings to array of key value', () => { 26 | const array = [ 27 | 'one', 28 | 'two', 29 | ]; 30 | 31 | const normalizedMap = normalizeMap(array); 32 | 33 | expect(normalizedMap).toEqual([ 34 | { key: 'one', val: 'one' }, 35 | { key: 'two', val: 'two' }, 36 | ]); 37 | }); 38 | }); 39 | 40 | describe('normalizeNamespaceName', () => { 41 | test('should directly returns empty string if namespace is empty', () => { 42 | const namespace = ''; 43 | const normalizedNamespace = normalizeNamespaceName(namespace); 44 | 45 | expect(normalizedNamespace).toEqual(''); 46 | }); 47 | 48 | test('should add slash at the end when none', () => { 49 | const namespace = 'test'; 50 | const normalizedNamespace = normalizeNamespaceName(namespace); 51 | 52 | expect(normalizedNamespace).toEqual('test/'); 53 | }); 54 | 55 | test('should not change if slash is at the end', () => { 56 | const namespace = 'test/'; 57 | const normalizedNamespace = normalizeNamespaceName(namespace); 58 | 59 | expect(normalizedNamespace).toEqual('test/'); 60 | }); 61 | }); 62 | 63 | describe('modulePathToNamespace', () => { 64 | test('should convert to namespace when modulePath is an array', () => { 65 | const modulePath = ['test', 'path']; 66 | const namespace = modulePathToNamespace(modulePath); 67 | 68 | expect(namespace).toEqual('test/path/'); 69 | }); 70 | 71 | test('should ignore empty string in array', () => { 72 | const modulePath = ['test', '']; 73 | const namespace = modulePathToNamespace(modulePath); 74 | 75 | expect(namespace).toEqual('test/'); 76 | }); 77 | 78 | test('should normalize namespace when modulePath is a string', () => { 79 | const modulePath = 'test/path'; 80 | const namespace = modulePathToNamespace(modulePath); 81 | 82 | expect(namespace).toEqual('test/path/'); 83 | }); 84 | 85 | test('should fallback to stringify when modulePath is not a string nor an array', () => { 86 | const modulePath = { test: 'path' }; 87 | const namespace = modulePathToNamespace(modulePath); 88 | 89 | expect(namespace).toEqual('{"test":"path"}/'); 90 | }); 91 | }); 92 | 93 | describe('cancellablePromiseWrapper', () => { 94 | test('should proxy resolve from actual promise to cancellable promise', async (done) => { 95 | const resolvedData = { data: 'test' }; 96 | 97 | let resolveCb; 98 | const promise = new Promise((resolve) => { 99 | resolveCb = resolve; 100 | }); 101 | 102 | const cancellable = cancellablePromiseWrapper(promise); 103 | 104 | cancellable.then((data) => { 105 | expect(data).toBe(resolvedData); 106 | done(); 107 | }); 108 | 109 | resolveCb(resolvedData); 110 | }); 111 | 112 | test('should proxy reject from actual promise to cancellable promise', async (done) => { 113 | const rejectMessage = { data: 'test' }; 114 | 115 | let rejectCb; 116 | const promise = new Promise((_, reject) => { 117 | rejectCb = reject; 118 | }); 119 | 120 | const cancellable = cancellablePromiseWrapper(promise); 121 | 122 | cancellable.catch((data) => { 123 | expect(data).toBe(rejectMessage); 124 | done(); 125 | }); 126 | 127 | rejectCb(rejectMessage); 128 | }); 129 | 130 | test('should reject with cancellationSymbol if cancelled', async (done) => { 131 | const promise = new Promise(() => {}); 132 | 133 | const cancellable = cancellablePromiseWrapper(promise); 134 | 135 | cancellable.catch((data) => { 136 | expect(data).toBe(cancellationSymbol); 137 | done(); 138 | }); 139 | 140 | cancellable.cancel(); 141 | }); 142 | 143 | test('should debounce function call and forward arguments', (done) => { 144 | const fn = jest.fn(); 145 | const dFn = debounce(fn, 1); 146 | 147 | dFn(); 148 | dFn('foo'); 149 | expect(fn).not.toHaveBeenCalled(); 150 | 151 | setTimeout(() => { 152 | expect(fn).toHaveBeenCalledTimes(1); 153 | expect(fn).toHaveBeenLastCalledWith('foo'); 154 | done(); 155 | }, 2); 156 | }); 157 | 158 | test('should immediate if delay is zero', () => { 159 | const fn = jest.fn(); 160 | const dFn = debounce(fn); // delay = 0 161 | 162 | dFn(); 163 | dFn(); 164 | expect(fn).toHaveBeenCalledTimes(2); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/SearchApi.js: -------------------------------------------------------------------------------- 1 | import Search from 'js-worker-search'; 2 | import { cancellablePromiseWrapper, cancellationSymbol } from './utils'; 3 | 4 | /* eslint-disable no-underscore-dangle */ 5 | 6 | /** 7 | * Observable that manages communication between vuex-search plugin and the Search utility. 8 | * This class maps resource names to search indicies and manages subscribers. 9 | */ 10 | export default class SubscribableSearchApi { 11 | /** 12 | * Constructor. 13 | */ 14 | constructor({ indexMode, tokenizePattern, caseSensitive } = {}) { 15 | this._indexMode = indexMode; 16 | this._tokenizePattern = tokenizePattern; 17 | this._caseSensitive = caseSensitive; 18 | this._resourceToSearchMap = {}; 19 | this._currentSearchPromiseMap = {}; 20 | 21 | // Subscribers 22 | this._onErrorSubscribers = []; 23 | this._onNextSubscribers = []; 24 | } 25 | 26 | /** 27 | * Subscribe to Search events. 28 | * Subscribers will be notified each time a Search is performed. 29 | * 30 | * Successful searches will call :onNext with the following parameters: 31 | * >result: An array of uids matching the search 32 | * >text: Search string 33 | * >resourceName: Identifies the resource that was searched 34 | * 35 | * Failed searches (searches that result in an Error) will call :onError with an Error parameter. 36 | * 37 | * This method returns a callback that can be used to unsubscribe from Search events. 38 | * Just invoke the function without any parameters to unsubscribe. 39 | */ 40 | subscribe(onNext, onError) { 41 | if (onNext) this._onNextSubscribers.push(onNext); 42 | if (onError) this._onErrorSubscribers.push(onError); 43 | 44 | // Return dispose function 45 | return () => { 46 | this._onNextSubscribers = this._onNextSubscribers.filter( 47 | subscriber => subscriber !== onNext, 48 | ); 49 | this._onErrorSubscribers = this._onErrorSubscribers.filter( 50 | subscriber => subscriber !== onError, 51 | ); 52 | }; 53 | } 54 | 55 | /** 56 | * Builds a searchable index of a set of resources. 57 | * 58 | * @param fieldNamesOrIndexFunction This value is passed to 59 | * vuexSearchPlugin() factory during initialization 60 | * It is either an Array of searchable fields (to be auto-indexed) 61 | * Or a custom index function to be called with a :resources object 62 | * and an :indexDocument callback 63 | * @param resourceName Uniquely identifies the resource (eg. "databases") 64 | * @param resources Map of resource uid to resource (Object) 65 | */ 66 | indexResource({ fieldNamesOrIndexFunction, resourceName, resources }) { 67 | const search = new Search({ 68 | indexMode: this._indexMode, 69 | tokenizePattern: this._tokenizePattern, 70 | caseSensitive: this._caseSensitive, 71 | }); 72 | 73 | if (Array.isArray(fieldNamesOrIndexFunction)) { 74 | if (resources.forEach instanceof Function) { 75 | resources.forEach((resource) => { 76 | fieldNamesOrIndexFunction.forEach((field) => { 77 | search.indexDocument(resource.id, resource[field] || ''); 78 | }); 79 | }); 80 | } else { 81 | Object.keys(resources).forEach((key) => { 82 | const resource = resources[key]; 83 | fieldNamesOrIndexFunction.forEach((field) => { 84 | search.indexDocument(resource.id, resource[field] || ''); 85 | }); 86 | }); 87 | } 88 | } else if (fieldNamesOrIndexFunction instanceof Function) { 89 | fieldNamesOrIndexFunction({ 90 | indexDocument: search.indexDocument, 91 | resources, 92 | }); 93 | } else { 94 | throw Error('Expected resource index to be either an Array of fields or an index function'); 95 | } 96 | 97 | this._resourceToSearchMap[resourceName] = search; 98 | } 99 | 100 | /** 101 | * Searches a resource and returns a Promise to be resolved with 102 | * an array of uids that match the search string. 103 | * Upon completion (or failure) this method also notifies all current subscribers. 104 | * 105 | * @param resourceName Uniquely identifies the resource (eg. "databases") 106 | * @param text Search string 107 | */ 108 | async performSearch(resourceName, text) { 109 | try { 110 | const search = this._resourceToSearchMap[resourceName]; 111 | const searchPromise = cancellablePromiseWrapper(search.search(text)); 112 | this._currentSearchPromiseMap[resourceName] = searchPromise; 113 | 114 | const result = await searchPromise; 115 | delete this._currentSearchPromiseMap[resourceName]; 116 | 117 | this._notifyNext({ 118 | result, 119 | text, 120 | resourceName, 121 | }); 122 | 123 | return result; 124 | } catch (error) { 125 | if (error === cancellationSymbol) return []; 126 | this._notifyError(error); 127 | 128 | throw error; 129 | } 130 | } 131 | 132 | /** 133 | * Stop search by resourceName if running. 134 | * Promise of search will be cancelled (rejected with CancellationError) 135 | */ 136 | stopSearch(resourceName) { 137 | const currentSearch = this._currentSearchPromiseMap[resourceName]; 138 | if (currentSearch) currentSearch.cancel(); 139 | } 140 | 141 | /** Notify all subscribes of :onError */ 142 | _notifyError(error) { 143 | this._onErrorSubscribers.forEach( 144 | subscriber => subscriber(error), 145 | ); 146 | } 147 | 148 | /** Notify all subscribes of :onNext */ 149 | _notifyNext(data) { 150 | this._onNextSubscribers.forEach( 151 | subscriber => subscriber(data), 152 | ); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /test/unit/specs/SearchApi.spec.js: -------------------------------------------------------------------------------- 1 | import { INDEX_MODES } from 'js-worker-search'; 2 | import { SearchApi } from 'vuex-search'; 3 | 4 | function getSearchApi({ 5 | indexMode, 6 | tokenizePattern, 7 | caseSensitive, 8 | useDictionary, 9 | fieldNamesOrIndexFunction, 10 | } = {}) { 11 | const documentA = { id: 1, name: 'One', description: 'The first document' }; 12 | const documentB = { id: 2, name: 'Two', description: 'The second document' }; 13 | const documentC = { id: 3, name: 'Three', description: 'The third document' }; 14 | const documentD = { id: 4, name: 'Four', description: 'The 4th (fourth) document' }; 15 | 16 | let resources = [documentA, documentB, documentC, documentD]; 17 | if (useDictionary) { 18 | resources = resources.reduce((acc, doc) => ({ ...acc, [doc.id]: doc }), {}); 19 | } 20 | // Single-threaded Search API for easier testing 21 | const searchApi = new SearchApi({ indexMode, tokenizePattern, caseSensitive }); 22 | searchApi.indexResource({ 23 | fieldNamesOrIndexFunction: fieldNamesOrIndexFunction || ['name', 'description'], 24 | resourceName: 'documents', 25 | resources, 26 | }); 27 | 28 | return searchApi; 29 | } 30 | 31 | describe('SearchApi', () => { 32 | test('performSearch: should return documents ids for any searchable field matching a query', async (done) => { 33 | const searchApi = getSearchApi(); 34 | const ids = await searchApi.performSearch('documents', 'One'); 35 | expect(ids.length).toEqual(1); 36 | expect(ids[0]).toEqual(1); 37 | done(); 38 | }); 39 | 40 | test('performSearch: should pass through the correct :indexMode for ALL_SUBSTRINGS', async (done) => { 41 | const searchApi = getSearchApi({ indexMode: INDEX_MODES.ALL_SUBSTRINGS }); 42 | 43 | const matches = await searchApi.performSearch('documents', 'econ'); 44 | expect(matches.length).toEqual(1); 45 | expect(matches[0]).toEqual(2); 46 | 47 | const noMatches = await searchApi.performSearch('documents', 'xyz'); 48 | expect(noMatches.length).toEqual(0); 49 | 50 | done(); 51 | }); 52 | 53 | test('performSearch: should pass through the correct :indexMode for PREFIXES', async (done) => { 54 | const searchApi = getSearchApi({ indexMode: INDEX_MODES.PREFIXES }); 55 | 56 | const matches = await searchApi.performSearch('documents', 'Thre'); 57 | expect(matches.length).toEqual(1); 58 | expect(matches[0]).toEqual(3); 59 | 60 | const noMatches = await searchApi.performSearch('documents', 'econd'); 61 | expect(noMatches.length).toEqual(0); 62 | 63 | done(); 64 | }); 65 | 66 | test('performSearch: should pass through the correct :indexMode for EXACT_WORDS', async (done) => { 67 | const searchApi = getSearchApi({ indexMode: INDEX_MODES.EXACT_WORDS }); 68 | 69 | const matches = await searchApi.performSearch('documents', 'One'); 70 | expect(matches.length).toEqual(1); 71 | expect(matches[0]).toEqual(1); 72 | 73 | const noMatches = await searchApi.performSearch('documents', 'seco'); 74 | expect(noMatches.length).toEqual(0); 75 | 76 | done(); 77 | }); 78 | 79 | test('performSearch: should pass through the correct :tokenizePattern', async (done) => { 80 | const searchApi = getSearchApi({ 81 | tokenizePattern: /[^a-z0-9]+/, 82 | }); 83 | 84 | const matches = await searchApi.performSearch('documents', 'fourth'); 85 | expect(matches.length).toEqual(1); 86 | expect(matches[0]).toEqual(4); 87 | 88 | done(); 89 | }); 90 | 91 | test('performSearch: should pass through the correct :caseSensitive bit', async (done) => { 92 | const searchApi = getSearchApi({ 93 | caseSensitive: true, 94 | }); 95 | 96 | let matches = await searchApi.performSearch('documents', 'Second'); 97 | expect(matches.length).toEqual(0); 98 | 99 | matches = await searchApi.performSearch('documents', 'second'); 100 | expect(matches.length).toEqual(1); 101 | expect(matches[0]).toEqual(2); 102 | 103 | done(); 104 | }); 105 | 106 | test('stopSearch: should return empty array of stopped search', async (done) => { 107 | const searchApi = getSearchApi(); 108 | const promise = searchApi.performSearch('documents', 'One'); 109 | searchApi.stopSearch('documents'); 110 | const ids = await promise; 111 | expect(ids.length).toEqual(0); 112 | done(); 113 | }); 114 | 115 | test('subscribe: notify subscribers for search result', async (done) => { 116 | const searchApi = getSearchApi(); 117 | 118 | const nextCb = jest.fn(); 119 | const errorCb = jest.fn(); 120 | 121 | const dispose = searchApi.subscribe(nextCb, errorCb); 122 | 123 | await searchApi.performSearch('documents', 'One'); 124 | expect(nextCb).toHaveBeenCalledTimes(1); 125 | expect(errorCb).not.toHaveBeenCalled(); 126 | 127 | dispose(); 128 | 129 | await searchApi.performSearch('documents', 'One'); 130 | expect(nextCb).toHaveBeenCalledTimes(1); 131 | expect(errorCb).not.toHaveBeenCalled(); 132 | 133 | done(); 134 | }); 135 | 136 | test('subscribe: notify subscribers for search result', async (done) => { 137 | const searchApi = getSearchApi(); 138 | 139 | const errorCb = jest.fn(); 140 | 141 | const dispose = searchApi.subscribe(undefined, errorCb); 142 | 143 | await searchApi.performSearch('documents', 'One'); 144 | expect(errorCb).not.toHaveBeenCalled(); 145 | 146 | dispose(); 147 | 148 | await searchApi.performSearch('documents', 'One'); 149 | expect(errorCb).not.toHaveBeenCalled(); 150 | 151 | done(); 152 | }); 153 | 154 | test('subscribe: notify subscribers for search error and throw error', async (done) => { 155 | const searchApi = getSearchApi(); 156 | 157 | const nextCb = jest.fn(); 158 | const errorCb = jest.fn(); 159 | 160 | const dispose = searchApi.subscribe(nextCb, errorCb); 161 | 162 | let error; 163 | try { 164 | await searchApi.performSearch('otherDocuments', 'One'); 165 | } catch (e) { 166 | error = e; 167 | } 168 | expect(error).not.toBeUndefined(); 169 | expect(nextCb).not.toHaveBeenCalled(); 170 | expect(errorCb).toHaveBeenCalledTimes(1); 171 | 172 | dispose(); 173 | 174 | error = undefined; 175 | try { 176 | await searchApi.performSearch('otherDocuments', 'One'); 177 | } catch (e) { 178 | error = e; 179 | } 180 | expect(error).not.toBeUndefined(); 181 | expect(nextCb).not.toHaveBeenCalled(); 182 | expect(errorCb).toHaveBeenCalledTimes(1); 183 | 184 | done(); 185 | }); 186 | 187 | test('indexResource: should not throw when resource is a dictionary', async (done) => { 188 | const searchApi = getSearchApi({ useDictionary: true }); 189 | const ids = await searchApi.performSearch('documents', 'One'); 190 | expect(ids.length).toEqual(1); 191 | expect(ids[0]).toEqual(1); 192 | done(); 193 | }); 194 | 195 | test('indexResource: should index normally using custom fieldNamesOrIndexFunction', async (done) => { 196 | const fieldNamesOrIndexFunction = ({ 197 | indexDocument, 198 | resources, 199 | }) => { 200 | resources.forEach((resource) => { 201 | indexDocument(resource.id, resource.name || ''); 202 | indexDocument(resource.id, resource.description || ''); 203 | }); 204 | }; 205 | 206 | const searchApi = getSearchApi({ fieldNamesOrIndexFunction }); 207 | const ids = await searchApi.performSearch('documents', 'One'); 208 | expect(ids.length).toEqual(1); 209 | expect(ids[0]).toEqual(1); 210 | done(); 211 | }); 212 | 213 | test('indexResource: should throw when fieldNamesOrIndexFunction is not an Array nor function', () => { 214 | const indexAsObject = {}; 215 | expect(() => getSearchApi({ fieldNamesOrIndexFunction: indexAsObject })).toThrow(); 216 | }); 217 | 218 | test('indexResource: should index empty string of unknown field', () => { 219 | const fieldNamesOrIndexFunction = ['unknownField']; 220 | expect(() => getSearchApi({ fieldNamesOrIndexFunction })).not.toThrow(); 221 | }); 222 | 223 | test('indexResource: should index empty string of unknown field using dictionary as resource', () => { 224 | const fieldNamesOrIndexFunction = ['unknownField']; 225 | expect(() => getSearchApi({ 226 | fieldNamesOrIndexFunction, 227 | useDictionary: true, 228 | })).not.toThrow(); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /src/VuexSearch.js: -------------------------------------------------------------------------------- 1 | import mutations from './mutations'; 2 | import getters from './getters'; 3 | import actionsWithSearch from './actions'; 4 | import * as actionTypes from './action-types'; 5 | import * as getterTypes from './getter-types'; 6 | import * as mutationTypes from './mutation-types'; 7 | import { debounce } from './utils'; 8 | 9 | /* eslint-disable no-underscore-dangle */ 10 | 11 | /** 12 | * Class that creates submodule in Vuex Store, and manages watched 13 | * states registration and unregistration, and SearchApi subscriptions. 14 | */ 15 | class VuexSearch { 16 | /** 17 | * Constructor. 18 | * 19 | * @param {Store} store A vuex store instance. 20 | * @param {[resourceName: string]: { getter, indexes, searchApi? }} resources 21 | * Options of resources and its index fields, getter, and optional searchApi. 22 | * @param {SearchApi} searchApi Custom SearchApi to be used and shared by resources 23 | * with no custom searchApi. 24 | */ 25 | constructor({ 26 | store, 27 | resources, 28 | searchApi, 29 | }) { 30 | this._base = VuexSearch.base; 31 | this._store = store; 32 | this._defaultSearchApi = searchApi; 33 | this._searchMap = {}; 34 | this._resourceOptions = {}; 35 | this._unwatchResource = {}; 36 | this._customSearch = new Map(); 37 | /* eslint-disable-next-line no-param-reassign */ 38 | store.search = this; 39 | 40 | this._initModule(); 41 | this._initResources(resources); 42 | } 43 | 44 | /** 45 | * Share map from resourceName to searchApi with actions 46 | * and register VuexSearch submodule on Vuex Store. 47 | */ 48 | _initModule() { 49 | const actions = actionsWithSearch(this._searchMap); 50 | 51 | this._store.registerModule(this._base, { 52 | namespaced: true, 53 | root: true, 54 | mutations, 55 | actions, 56 | getters, 57 | state: {}, 58 | }); 59 | } 60 | 61 | /** 62 | * Initialize all resources which are statically defined in store. 63 | * 64 | * @param {[resourceName: string]: { getter, indexes, watch?, searchApi? }} resources 65 | * Options of resources and its index fields, getter, and optional watch and searchApi 66 | */ 67 | _initResources(resources) { 68 | Object.entries(resources).forEach(([resourceName, config]) => { 69 | this.registerResource(resourceName, config); 70 | }); 71 | } 72 | 73 | /** 74 | * - Public API - 75 | * Dynamically register resource for indexing. 76 | * 77 | * @param resourceName Uniquely identifies the resource (eg. "databases"). 78 | * 79 | * config: 80 | * @param {(state: Object) => Array|Object} getter Function getter 81 | * to access resource and to be watched. 82 | * @param {string[]} index Fields to be indexed. 83 | * @param {Boolean|Object} [watch] Options to reindex if resource changes 84 | * @param {SearchApi} [searchApi] Custom SearchApi for this resource. 85 | */ 86 | registerResource(resourceName, config) { 87 | const store = this._store; 88 | const namespace = this._getNamespace(this._base); 89 | 90 | store.commit(`${namespace}${mutationTypes.SET_INIT_RESOURCE}`, { resourceName }); 91 | 92 | const { getter, index, watch = true, searchApi = this._defaultSearchApi } = config; 93 | 94 | this._searchMap[resourceName] = searchApi; 95 | this._resourceOptions[resourceName] = { getter, index }; 96 | 97 | this._searchSubscribeIfNecessary(searchApi, resourceName, (payload) => { 98 | this._store.dispatch(`${namespace}${actionTypes.RECEIVE_RESULT}`, payload); 99 | }); 100 | 101 | this.reindex(resourceName); 102 | 103 | if (watch) { 104 | const watchCb = () => { 105 | const searchString = this._getSearchText(resourceName); 106 | 107 | this.reindex(resourceName); 108 | this.search(resourceName, searchString); 109 | }; 110 | 111 | const { delay = 0 } = watch; 112 | 113 | this._unwatchResource[resourceName] = store.watch( 114 | getter, 115 | debounce(watchCb, delay), 116 | { deep: true }, 117 | ); 118 | } 119 | } 120 | 121 | /** 122 | * - Public API - 123 | * Search wrapper function for dispatching search action. 124 | * 125 | * @param {String} resourceName Uniquely identifies the resource (eg. "databases"). 126 | * @param {String} searchString Text to search. 127 | */ 128 | search(resourceName, searchString) { 129 | const store = this._store; 130 | const namespace = this._getNamespace(this._base); 131 | 132 | store.dispatch(`${namespace}${actionTypes.SEARCH}`, { 133 | resourceName, searchString, 134 | }); 135 | } 136 | 137 | /** 138 | * - Public API - 139 | * Reindex resource wrapper function for dispatching reindex action. 140 | * 141 | * This method is useful to avoid passing index fields and getter function 142 | * of the resource. 143 | * 144 | * @param {String} resourceName Uniquely identifies the resource (eg. "databases"). 145 | */ 146 | reindex(resourceName) { 147 | const store = this._store; 148 | const namespace = this._getNamespace(this._base); 149 | 150 | const { getter, index } = this._resourceOptions[resourceName]; 151 | 152 | store.dispatch(`${namespace}${actionTypes.searchApi.INDEX_RESOURCE}`, { 153 | fieldNamesOrIndexFunction: index, 154 | resourceName, 155 | resources: getter(store.state), 156 | }); 157 | 158 | const searchString = this._getSearchText(resourceName); 159 | this.search(resourceName, searchString); 160 | } 161 | 162 | /** 163 | * - Public API - 164 | * Unregister resource from indexing. 165 | * This method will unwatch state changes and unsubscribe from searchApi 166 | * used by the resource. 167 | * 168 | * @param resourceName Resource name to be unregistered. 169 | */ 170 | unregisterResource(resourceName) { 171 | const store = this._store; 172 | const namespace = this._getNamespace(this._base); 173 | 174 | delete this._resourceOptions[resourceName]; 175 | 176 | const searchApi = this._searchMap[resourceName]; 177 | this._searchUnsubscribeIfNecessary(searchApi, resourceName); 178 | searchApi.stopSearch(resourceName); 179 | delete this._searchMap[resourceName]; 180 | 181 | const unwatch = this._unwatchResource[resourceName]; 182 | if (unwatch instanceof Function) unwatch(); 183 | delete this._unwatchResource[resourceName]; 184 | 185 | store.commit(`${namespace}${mutationTypes.DELETE_RESOURCE}`, { resourceName }); 186 | } 187 | 188 | /** 189 | * Register resourceName to be kept tracked by customSearch map and check 190 | * whether need to subscribe if the searchApi is not yet subscribed. 191 | * 192 | * customSearch is a map from searchApi instance to list of resources using it 193 | * and unsubscribe callback. 194 | * 195 | * @param {SearchApi} searchApi SearchApi instance to be subscribed. 196 | * Will be checked if already been subscribed to prevent duplication. 197 | * @param resourceName Resource to be kept tracked by the map. 198 | * @param {({ result: string[], resourceName, text }) => void} fn callback to be subscribed. 199 | */ 200 | _searchSubscribeIfNecessary(searchApi, resourceName, fn) { 201 | const map = this._customSearch.get(searchApi); 202 | if (!map) { 203 | this._customSearch.set( 204 | searchApi, { 205 | unsubscribe: searchApi.subscribe(fn), 206 | resources: [resourceName], 207 | }); 208 | } else { 209 | map.resources.push(resourceName); 210 | } 211 | } 212 | 213 | /** 214 | * Remove a resource from searchApi's resources list and 215 | * unsubscribe searchApi if no resources using it anymore. 216 | * 217 | * @param {SearchApi} searchApi SearchApi instance to be unsubscribed. 218 | * @param resourceName Resource to be removed from customSearch map. 219 | */ 220 | _searchUnsubscribeIfNecessary(searchApi, resourceName) { 221 | const map = this._customSearch.get(searchApi); 222 | if (map.resources.length === 1) { 223 | map.unsubscribe(); 224 | this._customSearch.delete(searchApi); 225 | } else { 226 | map.resources = map.resources.filter(name => name !== resourceName); 227 | } 228 | } 229 | 230 | /** 231 | * Wrapper function for getting resource index search text. 232 | * 233 | * @param {String} resourceName 234 | * @returns {String} 235 | */ 236 | _getSearchText(resourceName) { 237 | const store = this._store; 238 | const namespace = this._getNamespace(this._base); 239 | 240 | return store.getters[`${namespace}${getterTypes.resourceIndexByName}`](resourceName).text; 241 | } 242 | 243 | /** 244 | * Get namespace from Vuex Store's modules' internal map of 245 | * module path to namespace. 246 | * @param {String} path 247 | * @returns {String} 248 | */ 249 | _getNamespace(...modulePath) { 250 | return this._store._modules.getNamespace(modulePath); 251 | } 252 | } 253 | 254 | /** 255 | * Generate map of actions to be exposed. 256 | */ 257 | function getPublicApi() { 258 | const publicApi = {}; 259 | Object 260 | .getOwnPropertyNames(VuexSearch.prototype) 261 | .filter(methodName => !methodName.startsWith('_')) 262 | .forEach((methodName) => { publicApi[methodName] = methodName; }); 263 | 264 | Object.freeze(publicApi); 265 | 266 | return publicApi; 267 | } 268 | 269 | /** 270 | * VuexSearch static property 'base'. 271 | */ 272 | let base = 'vuexSearch'; 273 | Object.defineProperty(VuexSearch, 'base', { 274 | get() { return base; }, 275 | set(newBase) { base = newBase; }, 276 | }); 277 | 278 | export const publicApi = getPublicApi(); 279 | export default VuexSearch; 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Vuex Search

2 | 3 |

4 | Coverage Status 5 | Build Status 6 | Downloads 7 | Downloads 8 | Version 9 | License 10 |

11 | 12 | > Vuex Search is a plugin for searching collections of objects. Search algorithms powered by [js-worker-search](https://github.com/bvaughn/js-worker-search). 13 | 14 |

Vuex Search

15 | 16 | ## See working example [here](https://albertlucianto.github.io/vuex-search). 17 | 18 | ## Installation: 19 | 20 | ```bash 21 | npm install --save vuex-search 22 | # or 23 | yarn add vuex-search 24 | ``` 25 | 26 | ## Overview 27 | 28 | vuex-search searches collections of documents and returns results as an `Array` of document ids. It is important to note that the documents themselves aren't returned. This is because the actual search is performed in a web-worker thread for performance reasons. In order to avoid serializing the documents and passing them back and forth, vuex-search simply passes their ids. 29 | 30 | Because of this, __each document must contain an `id` attribute.__ 31 | 32 | Please note that vuex-search depends on regenerator runtime, you need to either include `transform-runtime` plugin in your babel config, 33 | 34 | ```json 35 | { 36 | "plugins": [ 37 | "transform-runtime" 38 | ] 39 | } 40 | ``` 41 | 42 | or add `babel-polyfill` in your entries (assuming you are using webpack). For example 43 | 44 | ```js 45 | module.export = { 46 | entries: ['babel-polyfill', './src'] 47 | } 48 | ``` 49 | 50 | ## Examples 51 | 52 | ```javascript 53 | // store/state.js 54 | 55 | export default { 56 | myResources: { 57 | contacts: [ 58 | { 59 | // id is required for each record 60 | id: '1', 61 | address: '1 Hacker Way, Menlo Park', 62 | name: 'Dr. Katrina Stehr', 63 | }, 64 | { 65 | id: '2', 66 | address: '06176 Georgiana Points', 67 | name: 'Edyth Grimes', 68 | }, 69 | ], 70 | }, 71 | } 72 | ``` 73 | 74 | ### Vuex Search plugin 75 | 76 | #### `searchPlugin(options)` 77 | 78 | * __`options`__: List of options for defining the plugin. Available options are: 79 | 80 | * __`resources:`__ `{ [resourceName]: IndexOptions }` 81 | 82 | Dictionary of `resourceName` and their index options. [See `IndexOptions`.](#indexoptions) 83 | 84 | * __`[searchApi]:`__ `SearchApi` 85 | 86 | If provided, it will be used as default searchApi across resources. [See customizing search index.](#customizing-search-index) Default: `new SearchApi()` 87 | 88 | ```javascript 89 | // store/index.js 90 | 91 | import Vue from 'vue'; 92 | import Vuex from 'vuex'; 93 | import searchPlugin from 'vuex-search'; 94 | import state from './state'; 95 | 96 | Vue.use(Vuex); 97 | 98 | const store = new Vuex.Store({ 99 | state, 100 | plugins: [ 101 | searchPlugin({ 102 | resources: { 103 | contacts: { 104 | // what fields to index 105 | index: ['address', 'name'], 106 | // access the state to be watched by Vuex Search 107 | getter: state => state.myResources.contacts, 108 | // how resource should be watched 109 | watch: { delay: 500 }, 110 | }, 111 | // otherResource: { index, getter, watch, searchApi }, 112 | }, 113 | }), 114 | ], 115 | }); 116 | ``` 117 | 118 | #### `IndexOptions` 119 | 120 | * __`index:`__ `Array` 121 | 122 | List of fields to be indexed. 123 | 124 | * __`getter:`__ `(state) => Array|object` 125 | 126 | Getter function to access the resource from root state and to watch. 127 | 128 | * __`[watch]:`__ `boolean|WatchOptions` 129 | 130 | Whether needs to or delay reindex if resource changes. This option is useful to avoid reindex overhead when the resource frequently changes. Reindexing can be done by [mapping action `reindex`.](#mapactions(resourcename,-actionmap)) 131 | 132 | __`WatchOptions`__ 133 | 134 | * __`[delay]:`__ `number` 135 | 136 | If provided, reindex will be debounced with specified delay. 137 | 138 | Default: `true` 139 | 140 | * __`[searchApi]:`__ `SearchApi` 141 | 142 | [Custom search index.](#customizing-search-index) If defined, it is used instead of the shared `searchApi` instance. 143 | 144 | ### Binding with Vue Component 145 | 146 | ```javascript 147 | import { 148 | mapActions as mapSearchActions, 149 | mapGetters as mapSearchGetters, 150 | getterTypes, 151 | actionTypes, 152 | } from 'vuex-search'; 153 | ``` 154 | 155 | ```javascript 156 | // SomeComponent.vue 157 | 158 | data() { 159 | return { text: '' }, 160 | }, 161 | 162 | computed: { 163 | ...mapSearchGetters('contacts', { 164 | resultIds: getterTypes.result, 165 | isLoading: getterTypes.isSearching, 166 | }), 167 | }, 168 | 169 | methods: { 170 | ...mapSearchActions('contacts', { 171 | searchContacts: actionTypes.search, 172 | }), 173 | doSearch() { 174 | this.searchContacts(this.text); 175 | }, 176 | }, 177 | ``` 178 | 179 | #### `mapGetters(resourceName, getterMap)` 180 | 181 | Similar to Vuex helper for mapping attributes, `getterMap` can be either an object or an array. 182 | 183 | #### `mapActions(resourceName, actionMap)` 184 | 185 | Similar to Vuex helper for mapping attributes, `actionMap` can be either an object or an array. 186 | 187 | #### `getterTypes` 188 | 189 | * __`result`__ 190 | 191 | Mapped state is an array of ids. 192 | 193 | * __`isSearching`__ 194 | 195 | Mapped state indicates whether `searchApi` has resolved its promise of search result. 196 | 197 | * __`resourceIndex`__ 198 | 199 | Full state of resource index: `result`, `isSearching`, and current search `text`. 200 | 201 | #### `actionTypes` 202 | 203 | * __`search`__ 204 | 205 | Mapped action's function signature: `(query: string) => void`. 206 | 207 | * __`reindex`__ 208 | 209 | Mapped action's function signature: `() => void`. To be used when option `watch` is `false`. This action will reindex the resource and automatically re-search current text. 210 | 211 | * __`registerResource`__ 212 | 213 | Mapped action's function signature: `(options: IndexOptions) => void`. This action will dynamically add `resourceName` with options provided. [See `IndexOptions`.](#indexoptions) 214 | 215 | [More about Dynamic Index Registration.](#dynamic-index-registration) 216 | 217 | * __`unregisterResource`__ 218 | 219 | Mapped action's function signature: `() => void`. This action will unwatch and remove `resourceName` index. 220 | 221 | ### Customizing Search Index 222 | 223 | By default, vuex-search builds an index to match all substrings. 224 | You can override this behavior by providing your own, pre-configured `searchApi` param to the plugin like so: 225 | 226 | ```js 227 | import searchPlugin, { SearchApi, INDEX_MODES } from 'vuex-search'; 228 | 229 | // all-substrings match by default; same as current 230 | // eg 'c', 'ca', 'a', 'at', 'cat' match 'cat' 231 | const allSubstringsSearchApi = new SearchApi(); 232 | 233 | // prefix matching (eg 'c', 'ca', 'cat' match 'cat') 234 | const prefixSearchApi = new SearchApi({ 235 | indexMode: INDEX_MODES.PREFIXES, 236 | }); 237 | 238 | // exact words matching (eg only 'cat' matches 'cat') 239 | const exactWordsSearchApi = new SearchApi({ 240 | indexMode: INDEX_MODES.EXACT_WORDS, 241 | }); 242 | 243 | const store = new Vuex.Store({ 244 | state, 245 | plugins: [ 246 | searchPlugin({ 247 | resources: { 248 | contacts: { 249 | index: ['address', 'name'], 250 | getter: state => state.myResources.contacts, 251 | }, 252 | }, 253 | searchApi: exactWordsSearchApi, // or allSubstringSearchApi; or prefixSearchApi 254 | }), 255 | ], 256 | }); 257 | ``` 258 | 259 | ### Custom word boundaries (tokenization) and case-sensitivity 260 | 261 | You can also pass parameters to the SearchApi constructor that customize the way the 262 | search splits up the text into words (tokenizes) and change the search from the default 263 | case-insensitive to case-sensitive: 264 | 265 | ```js 266 | import searchPlugin, { SearchApi } from 'vuex-search'; 267 | 268 | const store = new Vuex.Store({ 269 | state, 270 | plugins: [ 271 | searchPlugin({ 272 | resources: { 273 | contacts: { 274 | index: ['address', 'name'], 275 | getter: state => state.myResources.contacts, 276 | }, 277 | }, 278 | searchApi: new SearchApi({ 279 | // split on all non-alphanumeric characters, 280 | // so this/that gets split to ['this','that'], for example 281 | tokenizePattern: /[^a-z0-9]+/, 282 | // make the search case-sensitive 283 | caseSensitive: true, 284 | }), 285 | }), 286 | ], 287 | }); 288 | ``` 289 | 290 | ### Dynamic Index Registration 291 | 292 | When a module needs to be loaded or registered dynamically, statically defined plugin can be a problem. The solution is to use vuex-search dynamic index registration. 293 | 294 | `VuexSearch` instance can be accessed through `search` attribute of `store`. Thus, in a Vue instance it is accessed through `this.$store.search`. Available methods are: 295 | 296 | #### `registerResource(resourceName, options: IndexOptions)` 297 | 298 | * __`options:`__ `IndexOptions` 299 | 300 | A list of options for indexing resource. [See `IndexOptions`.](#indexoptions) 301 | 302 | _Note that this method is slightly different from `registerResource` from `mapActions`. Calling this method needs to provide `resourceName`. Whereas, method from `mapActions` has already injected `resourceName` as its first argument._ 303 | 304 | 305 | #### `unregisterResource(resourceName)` 306 | 307 | Remove outdated resource indexes, and unwatch/unsubscribe any watchers/subscriptions related to `resourceName`. 308 | 309 | ### Changing Base 310 | 311 | By default, vuex-search will register its module in `'vuexSearch/'` from root state. To avoid possible clash naming, you can change its base name before defining the plugin in the store through 312 | 313 | ```js 314 | import { VuexSearch } from 'vuex-search'; 315 | 316 | VuexSearch.base = 'vuexSearchNew'; 317 | 318 | const store = new Vuex.Store({ 319 | // ... store options 320 | }); 321 | ``` 322 | 323 | Changelog 324 | --------- 325 | 326 | Changes are tracked in the [changelog](CHANGELOG.md). 327 | 328 | License 329 | --------- 330 | 331 | vuex-search is available under the MIT License. -------------------------------------------------------------------------------- /test/unit/specs/VuexSearch.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import { INDEX_MODES } from 'js-worker-search'; 4 | import SearchApi from 'vuex-search/SearchApi'; 5 | import VuexSearch from 'vuex-search/VuexSearch'; 6 | 7 | Vue.use(Vuex); 8 | 9 | function getByPath(obj, path) { 10 | return path.reduce((acc, key) => acc[key], obj); 11 | } 12 | 13 | const mutationTypes = { 14 | EDIT_VALUE: 'EDIT_VALUE', 15 | }; 16 | 17 | const mutations = { 18 | [mutationTypes.EDIT_VALUE]: (state, { 19 | resourcePath, 20 | key, 21 | value, 22 | }) => { 23 | Vue.set(getByPath(state, resourcePath), key, value); 24 | }, 25 | }; 26 | 27 | function getPlugin({ resources, searchApi = new SearchApi() }) { 28 | const documentA = { id: 1, name: 'One', description: 'The first document' }; 29 | const documentB = { id: 2, name: 'Two', description: 'The second document' }; 30 | const documentC = { id: 3, name: 'Three', description: 'The third document' }; 31 | const documentD = { id: 4, name: 'Four', description: 'The 4th (fourth) document' }; 32 | 33 | const contactA = { id: 1, name: 'One', description: 'The first contact' }; 34 | const contactB = { id: 2, name: 'Two', description: 'The second contact' }; 35 | const contactC = { id: 3, name: 'Three', description: 'The third contact' }; 36 | const contactD = { id: 4, name: 'Four', description: 'The 4th (fourth) contact' }; 37 | 38 | const initialState = { 39 | resources: { 40 | documents: [documentA, documentB, documentC, documentD], 41 | contacts: [contactA, contactB, contactC, contactD], 42 | }, 43 | }; 44 | 45 | const store = new Vuex.Store({ 46 | mutations, 47 | state: initialState, 48 | }); 49 | 50 | return new VuexSearch({ 51 | store, 52 | resources, 53 | searchApi, 54 | }); 55 | } 56 | 57 | describe('VuexSearch', () => { 58 | test('should index and start searching on initialisation', () => { 59 | const vuexSearch = getPlugin({ 60 | resources: { 61 | documents: { 62 | index: ['name', 'description'], 63 | getter: state => state.resources.documents, 64 | }, 65 | }, 66 | }); 67 | 68 | const store = vuexSearch._store; 69 | const documentIndex = store.state[vuexSearch._base].documents; 70 | expect(documentIndex).not.toBeUndefined(); 71 | 72 | expect(documentIndex.isSearching).toEqual(true); 73 | expect(documentIndex.result.length).toEqual(0); 74 | expect(documentIndex.text).toEqual(''); 75 | }); 76 | 77 | test('should dispatch search result after initialisation', async (done) => { 78 | const searchApi = new SearchApi(); 79 | const vuexSearch = getPlugin({ 80 | resources: { 81 | documents: { 82 | index: ['name', 'description'], 83 | getter: state => state.resources.documents, 84 | }, 85 | contacts: { 86 | index: ['name', 'description'], 87 | getter: state => state.resources.contacts, 88 | }, 89 | }, 90 | searchApi, 91 | }); 92 | 93 | const store = vuexSearch._store; 94 | let documentIndex = store.state[vuexSearch._base].documents; 95 | expect(documentIndex).not.toBeUndefined(); 96 | 97 | const subscribeCb = jest.fn(); 98 | searchApi.subscribe(subscribeCb); 99 | await new Promise((resolve) => { 100 | searchApi.subscribe(resolve); 101 | }); 102 | expect(subscribeCb).toHaveBeenCalledWith( 103 | { result: ['1', '2', '3', '4'], resourceName: 'documents', text: '' }, 104 | ); 105 | expect(subscribeCb).toHaveBeenCalledWith( 106 | { result: ['1', '2', '3', '4'], resourceName: 'contacts', text: '' }, 107 | ); 108 | 109 | documentIndex = store.state[vuexSearch._base].documents; 110 | expect(documentIndex.isSearching).toEqual(false); 111 | expect(documentIndex.result.length).toEqual(4); 112 | expect(documentIndex.text).toEqual(''); 113 | done(); 114 | }); 115 | 116 | test('should dispatch search result after search', async (done) => { 117 | const searchApi = new SearchApi(); 118 | const vuexSearch = getPlugin({ 119 | resources: { 120 | documents: { 121 | index: ['name', 'description'], 122 | getter: state => state.resources.documents, 123 | }, 124 | }, 125 | searchApi, 126 | }); 127 | 128 | const store = vuexSearch._store; 129 | let documentIndex = store.state[vuexSearch._base].documents; 130 | expect(documentIndex).not.toBeUndefined(); 131 | 132 | await new Promise((resolve) => { 133 | searchApi.subscribe(resolve); 134 | }); 135 | 136 | vuexSearch.search('documents', 'One'); 137 | 138 | documentIndex = store.state[vuexSearch._base].documents; 139 | expect(documentIndex.isSearching).toEqual(true); 140 | expect(documentIndex.text).toEqual('One'); 141 | 142 | const subscribeCb = jest.fn(); 143 | searchApi.subscribe(subscribeCb); 144 | await new Promise((resolve) => { 145 | searchApi.subscribe(resolve); 146 | }); 147 | 148 | expect(subscribeCb).toBeCalledWith( 149 | { result: [1], resourceName: 'documents', text: 'One' }, 150 | ); 151 | 152 | documentIndex = store.state[vuexSearch._base].documents; 153 | expect(documentIndex.isSearching).toEqual(false); 154 | expect(documentIndex.result.length).toEqual(1); 155 | expect(documentIndex.text).toEqual('One'); 156 | 157 | done(); 158 | }); 159 | 160 | test('should reindex and research on resource change', async (done) => { 161 | const searchApi = new SearchApi(); 162 | const vuexSearch = getPlugin({ 163 | resources: { 164 | documents: { 165 | index: ['name', 'description'], 166 | getter: state => state.resources.documents, 167 | }, 168 | }, 169 | searchApi, 170 | }); 171 | 172 | const store = vuexSearch._store; 173 | let documentIndex = store.state[vuexSearch._base].documents; 174 | expect(documentIndex).not.toBeUndefined(); 175 | 176 | await new Promise((resolve) => { 177 | searchApi.subscribe(resolve); 178 | }); 179 | 180 | vuexSearch.search('documents', 'New'); 181 | 182 | const subscribeCb = jest.fn(); 183 | searchApi.subscribe(subscribeCb); 184 | await new Promise((resolve) => { 185 | searchApi.subscribe(resolve); 186 | }); 187 | 188 | expect(subscribeCb).toBeCalledWith( 189 | { result: [], resourceName: 'documents', text: 'New' }, 190 | ); 191 | 192 | store.commit(mutationTypes.EDIT_VALUE, { 193 | resourcePath: ['resources', 'documents', 0], 194 | key: 'name', 195 | value: 'NewOne', 196 | }); 197 | 198 | Vue.nextTick(async () => { 199 | await new Promise((resolve) => { 200 | searchApi.subscribe(resolve); 201 | }); 202 | 203 | documentIndex = store.state[vuexSearch._base].documents; 204 | expect(documentIndex.isSearching).toEqual(false); 205 | expect(documentIndex.result.length).toEqual(1); 206 | expect(documentIndex.text).toEqual('New'); 207 | 208 | done(); 209 | }); 210 | }); 211 | 212 | test('should use custom searchApi if defined in resource option', async (done) => { 213 | const searchApiCustom = new SearchApi({ indexMode: INDEX_MODES.PREFIXES }); 214 | const searchApiShared = new SearchApi(); 215 | 216 | const vuexSearch = getPlugin({ 217 | resources: { 218 | documents: { 219 | index: ['name', 'description'], 220 | getter: state => state.resources.documents, 221 | searchApi: searchApiCustom, 222 | }, 223 | contacts: { 224 | index: ['name', 'description'], 225 | getter: state => state.resources.contacts, 226 | }, 227 | }, 228 | searchApi: searchApiShared, 229 | }); 230 | 231 | const store = vuexSearch._store; 232 | let documentIndex = store.state[vuexSearch._base].documents; 233 | expect(documentIndex).not.toBeUndefined(); 234 | 235 | expect(vuexSearch._searchMap.documents).not.toBe(searchApiShared); 236 | expect(vuexSearch._searchMap.documents).toBe(searchApiCustom); 237 | expect(vuexSearch._searchMap.contacts).not.toBe(searchApiCustom); 238 | expect(vuexSearch._searchMap.contacts).toBe(searchApiShared); 239 | 240 | vuexSearch.search('documents', 'econd'); 241 | 242 | let subscribeCb = jest.fn(); 243 | searchApiCustom.subscribe(subscribeCb); 244 | await new Promise((resolve) => { 245 | searchApiCustom.subscribe(resolve); 246 | }); 247 | 248 | // Documents index uses PREFIXES mode, thus search should yield 0 result 249 | documentIndex = store.state[vuexSearch._base].documents; 250 | expect(documentIndex.isSearching).toEqual(false); 251 | expect(documentIndex.result.length).toEqual(0); 252 | expect(documentIndex.text).toEqual('econd'); 253 | 254 | vuexSearch.search('contacts', 'econd'); 255 | 256 | subscribeCb = jest.fn(); 257 | searchApiShared.subscribe(subscribeCb); 258 | await new Promise((resolve) => { 259 | searchApiShared.subscribe(resolve); 260 | }); 261 | 262 | // Contacts index uses default, thus search should yield 1 result 263 | const contactIndex = store.state[vuexSearch._base].contacts; 264 | expect(contactIndex.isSearching).toEqual(false); 265 | expect(contactIndex.result.length).toEqual(1); 266 | expect(contactIndex.text).toEqual('econd'); 267 | 268 | done(); 269 | }); 270 | 271 | test('should unwatch / unsubscribe things related to resourceName', () => { 272 | const searchApiCustom = new SearchApi(); 273 | const searchApiShared = new SearchApi(); 274 | 275 | const vuexSearch = getPlugin({ 276 | resources: { 277 | documents: { 278 | index: ['name', 'description'], 279 | getter: state => state.resources.documents, 280 | searchApi: searchApiCustom, 281 | }, 282 | contacts: { 283 | index: ['name', 'description'], 284 | getter: state => state.resources.contacts, 285 | watch: false, 286 | }, 287 | }, 288 | searchApi: searchApiShared, 289 | }); 290 | 291 | const store = vuexSearch._store; 292 | let documentIndex = store.state[vuexSearch._base].documents; 293 | expect(documentIndex).not.toBeUndefined(); 294 | 295 | expect(vuexSearch._unwatchResource.documents instanceof Function).toBe(true); 296 | expect(vuexSearch._unwatchResource.contacts).toBeUndefined(); 297 | expect(searchApiCustom._onNextSubscribers.length).toEqual(1); 298 | 299 | vuexSearch.unregisterResource('documents'); 300 | expect(vuexSearch._unwatchResource.documents).toBeUndefined(); 301 | expect(vuexSearch._searchMap.documents).toBeUndefined(); 302 | // Ensure searchApi itself is not deleted 303 | expect(searchApiCustom).not.toBeUndefined(); 304 | documentIndex = store.state[vuexSearch._base].documents; 305 | expect(documentIndex).toBeUndefined(); 306 | expect(searchApiCustom._onNextSubscribers.length).toEqual(0); 307 | 308 | vuexSearch.unregisterResource('contacts'); 309 | expect(vuexSearch._searchMap.contacts).toBeUndefined(); 310 | // Ensure searchApi itself is not deleted 311 | expect(searchApiShared).not.toBeUndefined(); 312 | const contactIndex = store.state[vuexSearch._base].contacts; 313 | expect(contactIndex).toBeUndefined(); 314 | }); 315 | 316 | test('should keep track same custom searchApi for multiple resources', () => { 317 | const searchApi = new SearchApi(); 318 | 319 | const vuexSearch = getPlugin({ 320 | resources: { 321 | documents: { 322 | index: ['name', 'description'], 323 | getter: state => state.resources.documents, 324 | searchApi, 325 | }, 326 | contacts: { 327 | index: ['name', 'description'], 328 | getter: state => state.resources.contacts, 329 | searchApi, 330 | }, 331 | }, 332 | }); 333 | 334 | expect(searchApi._onNextSubscribers.length).toEqual(1); 335 | 336 | vuexSearch.unregisterResource('documents'); 337 | expect(searchApi._onNextSubscribers.length).toEqual(1); 338 | 339 | vuexSearch.unregisterResource('contacts'); 340 | expect(searchApi._onNextSubscribers.length).toEqual(0); 341 | }); 342 | 343 | test('should not auto reindex if set watch to false', async (done) => { 344 | const searchApi = new SearchApi(); 345 | const vuexSearch = getPlugin({ 346 | resources: { 347 | documents: { 348 | index: ['name', 'description'], 349 | getter: state => state.resources.documents, 350 | watch: false, 351 | }, 352 | }, 353 | searchApi, 354 | }); 355 | 356 | const store = vuexSearch._store; 357 | let documentIndex = store.state[vuexSearch._base].documents; 358 | expect(documentIndex).not.toBeUndefined(); 359 | 360 | await new Promise((resolve) => { 361 | searchApi.subscribe(resolve); 362 | }); 363 | 364 | store.commit(mutationTypes.EDIT_VALUE, { 365 | resourcePath: ['resources', 'documents', 0], 366 | key: 'name', 367 | value: 'Five', 368 | }); 369 | 370 | Vue.nextTick(async () => { 371 | vuexSearch.search('documents', 'Five'); 372 | 373 | await new Promise((resolve) => { 374 | searchApi.subscribe(resolve); 375 | }); 376 | 377 | documentIndex = store.state[vuexSearch._base].documents; 378 | expect(documentIndex.isSearching).toEqual(false); 379 | expect(documentIndex.result.length).toEqual(0); 380 | expect(documentIndex.text).toEqual('Five'); 381 | 382 | vuexSearch.reindex('documents'); 383 | 384 | await new Promise((resolve) => { 385 | searchApi.subscribe(resolve); 386 | }); 387 | 388 | documentIndex = store.state[vuexSearch._base].documents; 389 | expect(documentIndex.isSearching).toEqual(false); 390 | expect(documentIndex.result.length).toEqual(1); 391 | expect(documentIndex.text).toEqual('Five'); 392 | 393 | done(); 394 | }); 395 | }); 396 | 397 | test('should delay reindex if set watch delay option', async (done) => { 398 | const searchApi = new SearchApi(); 399 | const vuexSearch = getPlugin({ 400 | resources: { 401 | documents: { 402 | index: ['name', 'description'], 403 | getter: state => state.resources.documents, 404 | /** 405 | * delay shouldn't be too fast (e.g. 1) 406 | * because execution time itself may exceed that delay 407 | * causing fail test. 408 | */ 409 | watch: { delay: 10 }, 410 | }, 411 | }, 412 | searchApi, 413 | }); 414 | 415 | const store = vuexSearch._store; 416 | let documentIndex = store.state[vuexSearch._base].documents; 417 | expect(documentIndex).not.toBeUndefined(); 418 | 419 | await new Promise((resolve) => { 420 | searchApi.subscribe(resolve); 421 | }); 422 | 423 | store.commit(mutationTypes.EDIT_VALUE, { 424 | resourcePath: ['resources', 'documents', 0], 425 | key: 'name', 426 | value: 'Five', 427 | }); 428 | 429 | Vue.nextTick(async () => { 430 | vuexSearch.search('documents', 'Five'); 431 | 432 | await new Promise((resolve) => { 433 | searchApi.subscribe(resolve); 434 | }); 435 | 436 | documentIndex = store.state[vuexSearch._base].documents; 437 | expect(documentIndex.isSearching).toEqual(false); 438 | expect(documentIndex.result.length).toEqual(0); 439 | expect(documentIndex.text).toEqual('Five'); 440 | 441 | setTimeout(() => { 442 | documentIndex = store.state[vuexSearch._base].documents; 443 | expect(documentIndex.isSearching).toEqual(false); 444 | expect(documentIndex.result.length).toEqual(0); 445 | expect(documentIndex.text).toEqual('Five'); 446 | }, 5); 447 | 448 | setTimeout(() => { 449 | documentIndex = store.state[vuexSearch._base].documents; 450 | expect(documentIndex.isSearching).toEqual(false); 451 | expect(documentIndex.result.length).toEqual(1); 452 | expect(documentIndex.text).toEqual('Five'); 453 | 454 | done(); 455 | }, 20); 456 | }); 457 | }); 458 | 459 | test('should use new base name before plugin definition', () => { 460 | const oldBase = VuexSearch.base; 461 | const newBase = 'newBaseName'; 462 | VuexSearch.base = newBase; 463 | 464 | const vuexSearch = getPlugin({ 465 | resources: { 466 | documents: { 467 | index: ['name', 'description'], 468 | getter: state => state.resources.documents, 469 | }, 470 | }, 471 | }); 472 | 473 | expect(vuexSearch._base).toEqual(newBase); 474 | 475 | const store = vuexSearch._store; 476 | const documentIndex = store.state[newBase].documents; 477 | expect(documentIndex).not.toBeUndefined(); 478 | 479 | VuexSearch.base = oldBase; 480 | }); 481 | }); 482 | --------------------------------------------------------------------------------