├── .gitignore ├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .flowconfig ├── renovate.json ├── spec ├── .eslintrc.json ├── mocks.js ├── storageProvider.test.js ├── utils.test.js ├── save.test.js └── sort.test.js ├── .huskyrc.js ├── Jenkinsfile ├── .vscode └── settings.json ├── frecency.sublime-project ├── CHANGELOG.md ├── rollup.config.js ├── LICENSE ├── src ├── utils.js ├── types.js └── index.js ├── package.json ├── .github └── pull_request_template.md ├── README.md └── flow-typed └── npm └── jest_v22.x.x.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /dist 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "flow"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | rollup.config.js 2 | dist/ 3 | flow-typed/ 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["mixmax/browser", "mixmax/flow"], 3 | "globals": { 4 | "__SERVER__": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | flow-typed 7 | 8 | [lints] 9 | 10 | [options] 11 | 12 | [strict] 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>mixmaxhq/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /spec/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["mixmax/browser", "mixmax/flow"], 3 | "env": { 4 | "jest": true 5 | }, 6 | "globals": { 7 | "global": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@mixmaxhq/git-hooks'); 2 | 3 | // Husky explicitly greps for the hook itself to determine whether to run the hook. Here are the 4 | // hooks, to bypass this check: 5 | // 6 | // - pre-push 7 | // - commit-msg 8 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | // This name is defined in the Jenkins management console, and pulled from a github repository under 2 | // our mixmaxhq organization by the same name. Specify a specific tag/revision by appending it along 3 | // with an '@' symbol to the string literal. 4 | @Library('scm-service-library') _ 5 | 6 | npmModulePipeline {} 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "**/.git": true, 5 | "**/.DS_Store": true, 6 | }, 7 | "search.exclude": { 8 | "dist/": true, 9 | "**/node_modules": true, 10 | }, 11 | "editor.tabSize": 2, 12 | "files.insertFinalNewline": true, 13 | "editor.insertSpaces": true, 14 | "files.trimTrailingWhitespace": true, 15 | "editor.detectIndentation": true, 16 | "editor.rulers": [100] 17 | } 18 | -------------------------------------------------------------------------------- /frecency.sublime-project: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [{ 3 | "path": ".", 4 | "folder_exclude_patterns": [ 5 | "node_modules", 6 | "dist", 7 | ], 8 | "file_exclude_patterns": [ 9 | // Yarn lock file 10 | "yarn.lock" 11 | ] 12 | }], 13 | "settings": { 14 | "tab_size": 2, 15 | "ensure_newline_at_eof_on_save": true, 16 | "translate_tabs_to_spaces": true, 17 | "trim_trailing_white_space_on_save": true, 18 | "detect_indentation": false, 19 | "rulers": [100] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /spec/mocks.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export class LocalStorageMock { 3 | data: { [string]: ?string }; 4 | length: number; 5 | 6 | constructor() { 7 | this.data = {}; 8 | this.length = Object.keys(this.data).length; 9 | } 10 | 11 | getItem(key: string) { 12 | const value = this.data[key]; 13 | return (!value && typeof value !== 'string') ? null : value; 14 | } 15 | 16 | setItem(key: string, value: string) { 17 | this.data[key] = value; 18 | } 19 | 20 | removeItem(key: string) { 21 | delete this.data[key]; 22 | } 23 | 24 | key(n: number) { 25 | const key = Object.keys(this.data)[n]; 26 | const value = this.data[key]; 27 | return (!value && typeof value !== 'string') ? null : value; 28 | } 29 | 30 | clear() { 31 | this.data = {}; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spec/storageProvider.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Frecency from '../src'; 3 | import { LocalStorageMock } from './mocks'; 4 | 5 | describe('frecency', () => { 6 | beforeEach(() => { 7 | global.__SERVER__ = true; 8 | global.localStorage = new LocalStorageMock(); 9 | }); 10 | 11 | describe('#storageProvider', () => { 12 | it('should instantiate correctly with given storage provider', () => { 13 | const storageProvider = new LocalStorageMock(); 14 | expect(() => new Frecency({ key: 'templates', storageProvider })).not.toThrow('Missing Storage Provider'); 15 | }); 16 | 17 | it('should throw error if the storage provider is missing from global', () => { 18 | global.localStorage = undefined; 19 | expect(() => new Frecency({ key: 'templates' })).toThrow('Missing Storage Provider'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### [1.3.2](https://github.com/mixmaxhq/frecency/compare/v1.3.1...v1.3.2) (2020-01-18) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **import:** use relative paths to support webpack ([26dfda4](https://github.com/mixmaxhq/frecency/commit/26dfda4b83f2a72e9b9843557b73424e841e6432)) 7 | 8 | ### [1.0.5](https://github.com/mixmaxhq/frecency/compare/v1.0.4...v1.0.5) (2020-08-12) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **import:** use relative paths to support webpack ([4ddaa4c](https://github.com/mixmaxhq/frecency/commit/4ddaa4c65ba1681a68936150e5d00a995331ff95)) 14 | 15 | ## Release History 16 | 17 | * 1.3.1 fallback on other score computation when an entry is too old #11 18 | 19 | * 1.3.0 make weights configurable #12 20 | 21 | * 1.2.0 Add keepScores to SortParams [backward compatible] #10 22 | 23 | * 1.1.0 Add support for custom storage providers i.e. Node (#8 - @hugomano) 24 | 25 | * 1.0.4 Improve readme and install instructions. 26 | 27 | * 1.0.3 Add license. 28 | 29 | * 1.0.2 Fix unit tests. 30 | 31 | * 1.0.1 Allow sorting even if search query is empty. 32 | 33 | * 1.0.0 Initial release. 34 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import replace from 'rollup-plugin-replace'; 3 | 4 | const pkg = require('./package.json'); 5 | 6 | const presets = [ 7 | ['env', { modules: false }], 8 | 'flow' 9 | ]; 10 | 11 | const commonPlugins = [ 12 | babel({ 13 | babelrc: false, 14 | presets, 15 | plugins: [ 16 | 'external-helpers' 17 | ], 18 | exclude: [ 'node_modules/**' ] 19 | }), 20 | ]; 21 | 22 | const commonConfig = { 23 | input: 'src/index.js', 24 | }; 25 | 26 | export default [ 27 | { 28 | ...commonConfig, 29 | output: [ 30 | { 31 | format: 'es', 32 | file: pkg.browser['./index.js'], 33 | } 34 | ], 35 | plugins: commonPlugins.concat([ 36 | replace({ 37 | __SERVER__: JSON.stringify(false), 38 | }), 39 | ]), 40 | }, 41 | { 42 | ...commonConfig, 43 | output: [ 44 | { 45 | format: 'cjs', 46 | file: pkg.main, 47 | } 48 | ], 49 | plugins: commonPlugins.concat([ 50 | replace({ 51 | __SERVER__: JSON.stringify(true), 52 | }), 53 | ]), 54 | }, 55 | ] 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Mixmax, Inc 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 | -------------------------------------------------------------------------------- /spec/utils.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import { isSubQuery } from '../src/utils'; 3 | 4 | describe('utils', () => { 5 | describe('isSubQuery', () => { 6 | it('should be case-insensitive.', () => { 7 | expect(isSubQuery('BRAD', 'bradford')).toBe(true); 8 | }); 9 | 10 | it('should match full word.', () => { 11 | expect(isSubQuery('brad', 'brad')).toBe(true); 12 | }); 13 | 14 | it('should match multiple words.', () => { 15 | expect(isSubQuery('t d', 'team design')).toBe(true); 16 | expect(isSubQuery('tea des', 'team design')).toBe(true); 17 | expect(isSubQuery('Team Design', 'team design')).toBe(true); 18 | }); 19 | 20 | it('should match out of order', () => { 21 | expect(isSubQuery('Design', 'team design')).toBe(true); 22 | expect(isSubQuery('bee and bir', 'birds and bees')).toBe(true); 23 | expect(isSubQuery('for form fort', 'formula fortitude fortuitous')).toBe(true); 24 | }); 25 | 26 | it('should ignore extra whitespace', () => { 27 | expect(isSubQuery(' team design ', 'design team')).toBe(true); 28 | }); 29 | 30 | it('should not match if not a prefix', () => { 31 | expect(isSubQuery('vogel', 'brad')).toBe(false); 32 | expect(isSubQuery('rad', 'brad')).toBe(false); 33 | }); 34 | 35 | it('should not match if search string is longer', () => { 36 | expect(isSubQuery('bradford', 'brad')).toBe(false); 37 | }); 38 | 39 | it('should not match if one of the words in search string does not match', () => { 40 | expect(isSubQuery('tear design', 'design team')).toBe(false); 41 | expect(isSubQuery('design team team', 'design team')).toBe(false); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { StorageProvider } from './types'; 3 | 4 | declare var __SERVER__: boolean; 5 | 6 | /** 7 | * Performs a by-word prefix match to determine if a string is a sub query 8 | * of a given query. For example: 9 | * - 'de tea' is a subquery of 'design team' because 'de' is a substring of 'design' 10 | * and 'tea' is a substring of 'team'. 11 | * - 'team desi' is a subquery of 'design team' because we don't consider order. 12 | * @param {String} str - The string to test to see if its a subquery. 13 | * @param {String} query - The full query. 14 | * @return {Boolean} Whether str is a by-word prefix match of query. 15 | */ 16 | export function isSubQuery(str: ?string, query: string): boolean { 17 | if (!str) return false; 18 | 19 | // Split the string into words and order reverse-alphabetically. 20 | const searchStrings = str.toLowerCase().split(' ').sort((a, b) => b > a ? 1 : -1); 21 | const queryStrings = query.toLowerCase().split(' ').sort((a, b) => b > a ? 1 : -1); 22 | 23 | // Make sure each search string is a prefix of at least 1 word in the query strings. 24 | for (const searchString of searchStrings) { 25 | // Ignore extra whitespace. 26 | if (searchString === '') continue; 27 | 28 | const match = queryStrings.find((queryString) => { 29 | return queryString.startsWith(searchString); 30 | }); 31 | 32 | if (!match) return false; 33 | 34 | // Remove the matched query string so we don't match it again. 35 | queryStrings.splice(queryStrings.indexOf(match), 1); 36 | } 37 | 38 | return true; 39 | } 40 | 41 | // Switch to browser localStorage or raise an error if the storageProvider 42 | // is not provided and localStorage is not available 43 | export function loadStorageProvider(storageProvider: ?StorageProvider): ?StorageProvider { 44 | if (storageProvider) { 45 | return storageProvider; 46 | } 47 | 48 | if (!__SERVER__ && typeof localStorage !== 'undefined' && localStorageEnabled(localStorage)) { 49 | return localStorage; 50 | } 51 | 52 | if (__SERVER__) { 53 | throw new Error('Missing Storage Provider'); 54 | } 55 | } 56 | 57 | function localStorageEnabled(storageProvider: any) { 58 | const mod = '____featurecheck____'; 59 | try { 60 | storageProvider.setItem(mod, mod); 61 | storageProvider.removeItem(mod); 62 | return true; 63 | } catch (e) { 64 | return false; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frecency", 3 | "version": "1.3.2", 4 | "description": "Frecency sorting for search results.", 5 | "main": "./dist/main.js", 6 | "browser": { 7 | "./index.js": "./dist/browser.js" 8 | }, 9 | "files": [ 10 | "dist", 11 | "src" 12 | ], 13 | "scripts": { 14 | "build": "[ \"$WATCH\" == 'true' ] && rollup -cw || rollup -c", 15 | "ci": "npm run lint && npm run build", 16 | "ci:commitlint": "commitlint-jenkins --pr-only", 17 | "lint": "eslint . && flow", 18 | "prebuild": "rm -rf dist/", 19 | "prepublishOnly": "npm run build && if [ \"$CI\" = '' ]; then node -p 'JSON.parse(process.env.npm_package_config_manualPublishMessage)'; exit 1; fi", 20 | "semantic-release": "semantic-release", 21 | "test": "npm run build && jest", 22 | "watch": "WATCH=true yarn build" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/mixmaxhq/frecency.git" 27 | }, 28 | "author": "Mixmax (https://mixmax.com)", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/mixmaxhq/frecency/issues" 32 | }, 33 | "homepage": "https://github.com/mixmaxhq/frecency#readme", 34 | "devDependencies": { 35 | "@commitlint/cli": "^8.3.5", 36 | "@commitlint/config-conventional": "^8.3.4", 37 | "@mixmaxhq/commitlint-jenkins": "^1.4.4", 38 | "@mixmaxhq/git-hooks": "^1.1.0", 39 | "@mixmaxhq/semantic-release-config": "^2.0.0", 40 | "babel-core": "^6.26.0", 41 | "babel-jest": "^24.9.0", 42 | "babel-plugin-external-helpers": "^6.18.0", 43 | "babel-preset-env": "^1.6.1", 44 | "babel-preset-flow": "^6.23.0", 45 | "cz-conventional-changelog": "^3.0.2", 46 | "eslint": "^4.19.1", 47 | "eslint-config-mixmax": "^1.3.0", 48 | "eslint-plugin-flowtype": "^3.10.4", 49 | "flow-bin": "^0.75.0", 50 | "jest": "^24.9.0", 51 | "rollup": "^0.57.1", 52 | "rollup-plugin-babel": "^2.6.1", 53 | "rollup-plugin-replace": "^2.0.0", 54 | "semantic-release": "^17.2.3" 55 | }, 56 | "config": { 57 | "commitizen": { 58 | "path": "./node_modules/cz-conventional-changelog" 59 | }, 60 | "manualPublishMessage": "This repository is configured to use semantic-release for its releases. Please do not release manually.\n" 61 | }, 62 | "commitlint": { 63 | "extends": [ 64 | "@commitlint/config-conventional" 65 | ] 66 | }, 67 | "release": { 68 | "extends": "@mixmaxhq/semantic-release-config" 69 | }, 70 | "publishConfig": { 71 | "access": "public" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | export type FrecencyData = { 3 | // Stores information about which results the user selected based on 4 | // their search query and how frequently they selected these results. 5 | queries: { 6 | [searchQuery: string]: Array<{ 7 | id: string, 8 | 9 | // Total number of times this result was selected. 10 | timesSelected: number, 11 | 12 | // The list of timestamps of the most recent selections, which will 13 | // be used to calculate relevance scores for each result. 14 | selectedAt: number[] 15 | }>; 16 | }, 17 | 18 | // Stores information about how often a particular result has been chosen 19 | // based on its id. Use case: 20 | // 1. User searches "brad" and selects "brad vogel" very often. 21 | // 2. User searches "vogel" and "brad vogel" appears in the list of search results. 22 | // 3. Even though the user has never searched "vogel", we still want "brad vogel" 23 | // to rank higher because "brad vogel" has been selected very often. 24 | selections: { 25 | [id: string]: { 26 | 27 | // Total times this result was chosen regardless of the user's search query. 28 | timesSelected: number, 29 | 30 | // The list of timestamps of the most recent selections, which will 31 | // be used to calculate relevance scores for each result. 32 | selectedAt: number[], 33 | 34 | // The set of queries where the user selected this result. Used when removing results 35 | // from the frecency data in order to limit the size of the frecency object. 36 | queries: { [query: string]: true, } 37 | }, 38 | }, 39 | 40 | // Cache of recently selected IDs (ordered from most to least recent). When an ID is 41 | // selected we'll add or shift the ID to the front. When this list exceeds a certain 42 | // limit, we'll remove the last ID and remove all frecency data for this ID. 43 | recentSelections: string[]; 44 | }; 45 | 46 | export type StorageProvider = any & $ReadOnly<{ 47 | getItem: (key: string) => ?string, 48 | setItem: (key: string, value: string) => void, 49 | removeItem: (key: string) => void, 50 | key: (n: number) => ?string, 51 | clear: () => void, 52 | length: number 53 | }> 54 | 55 | export type FrecencyOptions = { 56 | key: string, 57 | timestampsLimit?: number, 58 | recentSelectionsLimit?: number, 59 | idAttribute?: string | Function, 60 | storageProvider?: StorageProvider, 61 | exactQueryMatchWeight?: number, 62 | subQueryMatchWeight?: number, 63 | recentSelectionsMatchWeight?: number, 64 | }; 65 | 66 | export type SaveParams = { 67 | searchQuery: ?string, 68 | selectedId: string 69 | }; 70 | 71 | export type SortParams = { 72 | searchQuery: ?string, 73 | results: Object[], 74 | keepScores?: boolean 75 | }; 76 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | > **Note** - Since this is a public repository, make sure that we're not publishing private data in the code, commit comments, or this PR. 4 | 5 | > **Note for reviewers** - Please add a 2nd reviewer if the PR affects more than 15 files or 100 lines (not counting 6 | `package-lock.json`), if it incurs significant risk, or if it is going through a 2nd review+fix cycle. 7 | 8 | ## 📚 Context/Description Behind The Change 9 | 18 | 19 | ## 🚨 Potential Risks & What To Monitor After Deployment 20 | 28 | 29 | ## 🧑‍🔬 How Has This Been Tested? 30 | 36 | 37 | ## 🚚 Release Plan 38 | 44 | 45 | 46 | 47 | 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Frecency 2 | 3 | Plugin to add frecency to search results. Original blog post on Frecency by Slack:
https://slack.engineering/a-faster-smarter-quick-switcher-77cbc193cb60 4 | 5 | ## Using The Module 6 | 7 | Install the npm module: 8 | ```sh 9 | npm install frecency 10 | ``` 11 | 12 | Import Frecency into your code and create an instance of Frecency. 13 | ```js 14 | import Frecency from 'frecency'; 15 | 16 | export const peopleFrecency = new Frecency({ 17 | key: 'people' // Frecency data will be saved in localStorage with the key: 'frecency_people'. 18 | }); 19 | ``` 20 | 21 | When you select a search result in your code, update frecency: 22 | ```js 23 | onSelect: (searchQuery, selectedResult) => { 24 | ... 25 | peopleFrecency.save({ 26 | searchQuery, 27 | selectedId: selectedResult._id 28 | }); 29 | ... 30 | } 31 | ``` 32 | 33 | Before you display search results to your users, sort the results using frecency: 34 | ```js 35 | onSearch: (searchQuery) => { 36 | ... 37 | // Search results received from a search API. 38 | const searchResults = [{ 39 | _id: '57b409d4feea972a68ba1101', 40 | name: 'Brad Vogel', 41 | email: 'brad@mixmax.com' 42 | }, { 43 | _id: '57a09ceb7abdf9cb2c35818c', 44 | name: 'Brad Neuberg', 45 | email: 'neuberg@gmail.com' 46 | }, { 47 | ... 48 | }]; 49 | 50 | return peopleFrecency.sort({ 51 | searchQuery, 52 | results: searchResults 53 | }); 54 | } 55 | ``` 56 | 57 | Frecency adds and removes `_frecencyScore` attribute for compare results. 58 | You can output results with scores by assigning `keepScores` parameter to `true`. 59 | 60 | Keep the frecency score allow you to do extra operations like usage analytics, debugging 61 | and/or mix with other algorithms. 62 | 63 | 64 | ## Configuring Frecency 65 | Frecency will sort on the `_id` attribute by default. You can change this by setting an 66 | `idAttribute` in the constructor: 67 | ```js 68 | const frecency = new Frecency({ 69 | key: 'people', 70 | idAttribute: 'id' 71 | }); 72 | 73 | // OR 74 | 75 | const frecency = new Frecency({ 76 | key: 'people', 77 | idAttribute: 'email' 78 | }); 79 | 80 | // Then when saving frecency, make sure to save the correct attribute as the selectedId. 81 | frecency.save({ 82 | searchQuery, 83 | selectedId: selectedResult.email 84 | }); 85 | 86 | // `idAttribute` also accepts a function if your search results contain a 87 | // mix of different types. 88 | const frecency = new Frecency({ 89 | key: 'people', 90 | idAttribute: (result) => result.id || result.email 91 | }); 92 | 93 | // Depending on the result, save the appropriate ID in frecency. 94 | frecency.save({ 95 | searchQuery, 96 | selectedId: selectedResult.id 97 | }); 98 | 99 | // OR 100 | 101 | frecency.save({ 102 | searchQuery, 103 | selectedId: selectedResult.email 104 | }); 105 | ``` 106 | 107 | Frecency saves timestamps of your recent selections to calculate a score and rank you results. 108 | More timestamps means more granular frecency scores, but frecency data will take up more 109 | space in localStorage. 110 | 111 | You can modify the number of timestamps saved with an option in the constructor. 112 | ```js 113 | new Frecency({ 114 | key: 'people', 115 | timeStampsLimit: 20 // Limit is 10 by default. 116 | }); 117 | ``` 118 | 119 | Frecency stores a maximum number of IDs in localStorage. More IDs means more results 120 | can be sorted with frecency, but frecency data takes up more space in localStorage. 121 | 122 | To change the maximum number of different IDs stored in frecency: 123 | ```js 124 | new Frecency({ 125 | key: 'people', 126 | recentSelectionsLimit: 200 // Limit is 100 by default. 127 | }); 128 | ``` 129 | 130 | By default, frecency uses browser localStorage as storage provider in the browser environment. 131 | You can pass your own storage provider that implements the API Web Storage interface. 132 | For Node.js environment you can use a storage provider like [node-localstorage](https://github.com/lmaccherone/node-localstorage) 133 | ```js 134 | const storageProviderFrecencyFilePath = path.join(app.getPath('userData'), 'frecency'); 135 | const storageProvider = new LocalStorage(storageProviderFrecencyFilePath); 136 | new Frecency({ 137 | key: 'people', 138 | storageProvider 139 | }); 140 | ``` 141 | 142 | ### Configure weights 143 | 144 | Differents weights are applied depending on what kind of match it is about 145 | 146 | ```js 147 | new Frecency({ 148 | key: 'people', 149 | exactQueryMatchWeight: 0.9, // default to 1.0 150 | subQueryMatchWeight: 0.5, // default to 0.7 151 | recentSelectionsMatchWeight: 0.1, // default to 0.5 152 | }); 153 | ``` 154 | -------------------------------------------------------------------------------- /spec/save.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Frecency from '../src'; 3 | import { LocalStorageMock } from './mocks'; 4 | 5 | describe('frecency', () => { 6 | beforeEach(() => { 7 | global.__SERVER__ = false; 8 | global.localStorage = new LocalStorageMock(); 9 | }); 10 | 11 | describe('#save', () => { 12 | it('should not throw if localStorage is disabled.', () => { 13 | global.localStorage = undefined; 14 | const frecency = new Frecency({ key: 'templates' }); 15 | 16 | expect(frecency.save({ 17 | searchQuery: 'search', 18 | selectedId: 'test' 19 | })).toBeUndefined(); 20 | }); 21 | 22 | it('stores multiple queries.', () => { 23 | const frecency = new Frecency({ key: 'templates' }); 24 | 25 | global.Date.now = jest.fn(() => 1524085045510); 26 | frecency.save({ 27 | searchQuery: 'brad', 28 | selectedId: 'brad vogel' 29 | }); 30 | 31 | global.Date.now = jest.fn(() => 1524270045510); 32 | frecency.save({ 33 | searchQuery: 'simon xi', 34 | selectedId: 'simon xiong' 35 | }); 36 | 37 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 38 | expect(data).toEqual({ 39 | queries: { 40 | brad: [{ 41 | id: 'brad vogel', 42 | timesSelected: 1, 43 | selectedAt: [1524085045510] 44 | }], 45 | 'simon xi': [{ 46 | id: 'simon xiong', 47 | timesSelected: 1, 48 | selectedAt: [1524270045510] 49 | }], 50 | }, 51 | selections: { 52 | 'brad vogel': { 53 | timesSelected: 1, 54 | selectedAt: [1524085045510], 55 | queries: { brad: true } 56 | }, 57 | 'simon xiong': { 58 | timesSelected: 1, 59 | selectedAt: [1524270045510], 60 | queries: { 'simon xi': true } 61 | } 62 | }, 63 | recentSelections: ['simon xiong', 'brad vogel', ] 64 | }); 65 | }); 66 | 67 | it('stores different selections for the same query.', () => { 68 | const frecency = new Frecency({ key: 'templates' }); 69 | 70 | global.Date.now = jest.fn(() => 1524085045510); 71 | frecency.save({ 72 | searchQuery: 'brad', 73 | selectedId: 'brad vogel' 74 | }); 75 | 76 | global.Date.now = jest.fn(() => 1524270045510); 77 | frecency.save({ 78 | searchQuery: 'brad', 79 | selectedId: 'brad neuberg' 80 | }); 81 | 82 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 83 | expect(data).toEqual({ 84 | queries: { 85 | brad: [{ 86 | id: 'brad vogel', 87 | timesSelected: 1, 88 | selectedAt: [1524085045510] 89 | }, { 90 | id: 'brad neuberg', 91 | timesSelected: 1, 92 | selectedAt: [1524270045510] 93 | }], 94 | }, 95 | selections: { 96 | 'brad vogel': { 97 | timesSelected: 1, 98 | selectedAt: [1524085045510], 99 | queries: { brad: true } 100 | }, 101 | 'brad neuberg': { 102 | timesSelected: 1, 103 | selectedAt: [1524270045510], 104 | queries: { brad: true } 105 | }, 106 | }, 107 | 108 | recentSelections: ['brad neuberg', 'brad vogel'] 109 | }); 110 | }); 111 | 112 | it('stores selections multiple times.', () => { 113 | const frecency = new Frecency({ key: 'templates' }); 114 | 115 | global.Date.now = jest.fn(() => 1524085045510); 116 | frecency.save({ 117 | searchQuery: 'brad', 118 | selectedId: 'brad vogel' 119 | }); 120 | 121 | global.Date.now = jest.fn(() => 1524270045510); 122 | frecency.save({ 123 | searchQuery: 'brad', 124 | selectedId: 'brad vogel' 125 | }); 126 | 127 | global.Date.now = jest.fn(() => 1524351045510); 128 | frecency.save({ 129 | searchQuery: 'brad', 130 | selectedId: 'brad vogel' 131 | }); 132 | 133 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 134 | expect(data).toEqual({ 135 | queries: { 136 | brad: [{ 137 | id: 'brad vogel', 138 | timesSelected: 3, 139 | selectedAt: [1524085045510, 1524270045510, 1524351045510] 140 | }] 141 | }, 142 | selections: { 143 | 'brad vogel': { 144 | timesSelected: 3, 145 | selectedAt: [1524085045510, 1524270045510, 1524351045510], 146 | queries: { brad: true } 147 | }, 148 | }, 149 | recentSelections: ['brad vogel'] 150 | }); 151 | }); 152 | 153 | it('stores same selections with different queries.', () => { 154 | const frecency = new Frecency({ key: 'templates' }); 155 | 156 | global.Date.now = jest.fn(() => 1524085045510); 157 | frecency.save({ 158 | searchQuery: 'brad', 159 | selectedId: 'brad vogel' 160 | }); 161 | 162 | global.Date.now = jest.fn(() => 1524301045510); 163 | frecency.save({ 164 | searchQuery: 'brad', 165 | selectedId: 'brad vogel' 166 | }); 167 | 168 | global.Date.now = jest.fn(() => 1524346045510); 169 | frecency.save({ 170 | searchQuery: 'vogel', 171 | selectedId: 'brad vogel' 172 | }); 173 | 174 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 175 | expect(data).toEqual({ 176 | queries: { 177 | brad: [{ 178 | id: 'brad vogel', 179 | timesSelected: 2, 180 | selectedAt: [1524085045510, 1524301045510] 181 | }], 182 | vogel: [{ 183 | id: 'brad vogel', 184 | timesSelected: 1, 185 | selectedAt: [1524346045510] 186 | }] 187 | }, 188 | selections: { 189 | 'brad vogel': { 190 | timesSelected: 3, 191 | selectedAt: [1524085045510, 1524301045510, 1524346045510], 192 | queries: { brad: true, vogel: true } 193 | } 194 | }, 195 | recentSelections: ['brad vogel'] 196 | }); 197 | }); 198 | 199 | it('limits number of timestamps per query.', () => { 200 | const frecency = new Frecency({ 201 | key: 'templates', 202 | timestampsLimit: 3 203 | }); 204 | 205 | [1524085000000, 1524086000000, 1524087000000, 1524088000000].forEach((time) => { 206 | global.Date.now = jest.fn(() => time); 207 | frecency.save({ 208 | searchQuery: 'brad', 209 | selectedId: 'brad vogel' 210 | }); 211 | }); 212 | 213 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 214 | expect(data).toEqual({ 215 | queries: { 216 | brad: [{ 217 | id: 'brad vogel', 218 | timesSelected: 4, 219 | selectedAt: [1524086000000, 1524087000000, 1524088000000] 220 | }] 221 | }, 222 | selections: { 223 | 'brad vogel': { 224 | timesSelected: 4, 225 | selectedAt: [1524086000000, 1524087000000, 1524088000000], 226 | queries: { brad: true } 227 | } 228 | }, 229 | recentSelections: ['brad vogel'] 230 | }); 231 | }); 232 | 233 | it('limits number of timestamps per selection.', () => { 234 | const frecency = new Frecency({ 235 | key: 'templates', 236 | timestampsLimit: 3 237 | }); 238 | 239 | [1524085000000, 1524086000000, 1524087000000, 1524088000000].forEach((time, index) => { 240 | global.Date.now = jest.fn(() => time); 241 | frecency.save({ 242 | searchQuery: index % 2 === 0 ? 'brad' : 'vogel', 243 | selectedId: 'brad vogel' 244 | }); 245 | }); 246 | 247 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 248 | expect(data).toEqual({ 249 | queries: { 250 | brad: [{ 251 | id: 'brad vogel', 252 | timesSelected: 2, 253 | selectedAt: [1524085000000, 1524087000000] 254 | }], 255 | vogel: [{ 256 | id: 'brad vogel', 257 | timesSelected: 2, 258 | selectedAt: [1524086000000, 1524088000000] 259 | }] 260 | }, 261 | selections: { 262 | 'brad vogel': { 263 | timesSelected: 4, 264 | selectedAt: [1524086000000, 1524087000000, 1524088000000], 265 | queries: { brad: true, vogel: true } 266 | } 267 | }, 268 | recentSelections: ['brad vogel'] 269 | }); 270 | }); 271 | 272 | it('limits number of IDs saved in frecency.', () => { 273 | const frecency = new Frecency({ 274 | key: 'templates', 275 | recentSelectionsLimit: 2 276 | }); 277 | 278 | global.Date.now = jest.fn(() => 1524085045510); 279 | frecency.save({ 280 | searchQuery: 'brad', 281 | selectedId: 'brad vogel' 282 | }); 283 | 284 | global.Date.now = jest.fn(() => 1524270045510); 285 | frecency.save({ 286 | searchQuery: 'brad', 287 | selectedId: 'brad neuberg' 288 | }); 289 | 290 | global.Date.now = jest.fn(() => 1524360045510); 291 | frecency.save({ 292 | searchQuery: 'simon xi', 293 | selectedId: 'simon xiong' 294 | }); 295 | 296 | global.Date.now = jest.fn(() => 1524490045510); 297 | frecency.save({ 298 | searchQuery: 'simon', 299 | selectedId: 'simon xiong' 300 | }); 301 | 302 | const data = JSON.parse((localStorage.getItem('frecency_templates'): any)); 303 | expect(data).toEqual({ 304 | queries: { 305 | brad: [{ 306 | id: 'brad neuberg', 307 | timesSelected: 1, 308 | selectedAt: [1524270045510] 309 | }], 310 | 'simon xi': [{ 311 | id: 'simon xiong', 312 | timesSelected: 1, 313 | selectedAt: [1524360045510] 314 | }], 315 | simon: [{ 316 | id: 'simon xiong', 317 | timesSelected: 1, 318 | selectedAt: [1524490045510] 319 | }] 320 | }, 321 | selections: { 322 | 'brad neuberg': { 323 | timesSelected: 1, 324 | selectedAt: [1524270045510], 325 | queries: { brad: true } 326 | }, 327 | 'simon xiong': { 328 | timesSelected: 2, 329 | selectedAt: [1524360045510, 1524490045510], 330 | queries: { 'simon xi': true, simon: true } 331 | } 332 | }, 333 | recentSelections: ['simon xiong', 'brad neuberg'] 334 | }); 335 | }); 336 | }); 337 | }); 338 | -------------------------------------------------------------------------------- /spec/sort.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import Frecency from '../src'; 3 | import { LocalStorageMock } from './mocks'; 4 | 5 | const hour = 1000 * 60 * 60; 6 | const day = 24 * hour; 7 | 8 | describe('frecency', () => { 9 | beforeEach(() => { 10 | global.__SERVER__ = false; 11 | global.localStorage = new LocalStorageMock(); 12 | }); 13 | 14 | describe('#sort', () => { 15 | it('should not throw if localStorage is disabled.', () => { 16 | global.localStorage = undefined; 17 | const frecency = new Frecency({ key: 'templates' }); 18 | 19 | expect(frecency.sort({ 20 | searchQuery: 'brad', 21 | results: [{ 22 | _id: 'brad vogel' 23 | }, { 24 | _id: 'simon xiong' 25 | }] 26 | })).toEqual([{ 27 | _id: 'brad vogel' 28 | }, { 29 | _id: 'simon xiong' 30 | }]); 31 | }); 32 | 33 | it('should not sort if frecency is empty.', () => { 34 | const frecency = new Frecency({ key: 'templates' }); 35 | 36 | const results = frecency.sort({ 37 | searchQuery: 'brad', 38 | results: [{ 39 | _id: 'brad vogel' 40 | }, { 41 | _id: 'simon xiong' 42 | }, { 43 | _id: 'brad neuberg' 44 | }] 45 | }); 46 | 47 | expect(results).toEqual([{ 48 | _id: 'brad vogel' 49 | }, { 50 | _id: 'simon xiong' 51 | }, { 52 | _id: 'brad neuberg' 53 | }]); 54 | }); 55 | 56 | it('should sort if search query is empty.', () => { 57 | const frecency = new Frecency({ key: 'templates' }); 58 | const now = 1524085045510; 59 | 60 | global.Date.now = jest.fn(() => now - 1 * hour); 61 | frecency.save({ 62 | searchQuery: 'brad', 63 | selectedId: 'brad neuberg' 64 | }); 65 | 66 | global.Date.now = jest.fn(() => now); 67 | 68 | const results = frecency.sort({ 69 | searchQuery: '', 70 | results: [{ 71 | _id: 'brad vogel' 72 | }, { 73 | _id: 'simon xiong' 74 | }, { 75 | _id: 'brad neuberg' 76 | }] 77 | }); 78 | 79 | expect(results).toEqual([{ 80 | _id: 'brad neuberg' 81 | }, { 82 | _id: 'brad vogel' 83 | }, { 84 | _id: 'simon xiong' 85 | }]); 86 | }); 87 | 88 | it('should sort higher if search query was recently selected.', () => { 89 | const frecency = new Frecency({ key: 'templates' }); 90 | const now = 1524085045510; 91 | 92 | global.Date.now = jest.fn(() => now - 1 * hour); 93 | frecency.save({ 94 | searchQuery: 'brad', 95 | selectedId: 'brad neuberg' 96 | }); 97 | 98 | global.Date.now = jest.fn(() => now); 99 | 100 | const results = frecency.sort({ 101 | searchQuery: 'brad', 102 | results: [{ 103 | _id: 'brad vogel' 104 | }, { 105 | _id: 'simon xiong' 106 | }, { 107 | _id: 'brad neuberg' 108 | }] 109 | }); 110 | 111 | expect(results).toEqual([{ 112 | _id: 'brad neuberg' 113 | }, { 114 | _id: 'brad vogel' 115 | }, { 116 | _id: 'simon xiong' 117 | }]); 118 | }); 119 | 120 | it('should sort higher if search query is a subquery of recent selected query.', () => { 121 | const frecency = new Frecency({ key: 'templates' }); 122 | const now = 1524085045510; 123 | 124 | global.Date.now = jest.fn(() => now - 1 * hour); 125 | frecency.save({ 126 | searchQuery: 'brad', 127 | selectedId: 'brad neuberg' 128 | }); 129 | 130 | global.Date.now = jest.fn(() => now); 131 | 132 | const results = frecency.sort({ 133 | searchQuery: 'br', 134 | results: [{ 135 | _id: 'brad vogel' 136 | }, { 137 | _id: 'simon xiong' 138 | }, { 139 | _id: 'brad neuberg' 140 | }] 141 | }); 142 | 143 | expect(results).toEqual([{ 144 | _id: 'brad neuberg' 145 | }, { 146 | _id: 'brad vogel' 147 | }, { 148 | _id: 'simon xiong' 149 | }]); 150 | }); 151 | 152 | it('should sort higher if an ID was recently selected.', () => { 153 | const frecency = new Frecency({ key: 'templates' }); 154 | const now = 1524085045510; 155 | 156 | global.Date.now = jest.fn(() => now - 1 * hour); 157 | frecency.save({ 158 | searchQuery: 'brad', 159 | selectedId: 'brad neuberg' 160 | }); 161 | 162 | global.Date.now = jest.fn(() => now); 163 | 164 | const results = frecency.sort({ 165 | searchQuery: 'neuberg', 166 | results: [{ 167 | _id: 'brad vogel' 168 | }, { 169 | _id: 'simon xiong' 170 | }, { 171 | _id: 'brad neuberg' 172 | }] 173 | }); 174 | 175 | expect(results).toEqual([{ 176 | _id: 'brad neuberg' 177 | }, { 178 | _id: 'brad vogel' 179 | }, { 180 | _id: 'simon xiong' 181 | }]); 182 | }); 183 | 184 | it('should sort higher if selections are more recent.', () => { 185 | const frecency = new Frecency({ key: 'templates' }); 186 | const now = 1524085045510; 187 | 188 | // We select brad vogel 3 times, but many days earlier. 189 | for (let i = 0; i < 3; ++i) { 190 | global.Date.now = jest.fn(() => now - 7 * day); 191 | frecency.save({ 192 | searchQuery: 'brad', 193 | selectedId: 'brad vogel' 194 | }); 195 | } 196 | 197 | // We select brad neuberg 2 times, but within the last hour. 198 | for (let i= 0; i < 2; ++i) { 199 | global.Date.now = jest.fn(() => now - 1 * hour); 200 | frecency.save({ 201 | searchQuery: 'brad', 202 | selectedId: 'brad neuberg' 203 | }); 204 | } 205 | 206 | global.Date.now = jest.fn(() => now); 207 | 208 | const results = frecency.sort({ 209 | searchQuery: 'brad', 210 | results: [{ 211 | _id: 'brad vogel' 212 | }, { 213 | _id: 'simon xiong' 214 | }, { 215 | _id: 'brad neuberg' 216 | }] 217 | }); 218 | 219 | expect(results).toEqual([{ 220 | _id: 'brad neuberg' 221 | }, { 222 | _id: 'brad vogel' 223 | }, { 224 | _id: 'simon xiong' 225 | }]); 226 | }); 227 | 228 | it('should give non-exact matches a reduced score.', () => { 229 | const frecency = new Frecency({ key: 'templates' }); 230 | const now = 1524085045510; 231 | 232 | // We'll use this as an exact match. 233 | global.Date.now = jest.fn(() => now - 1 * hour); 234 | frecency.save({ 235 | searchQuery: 'br', 236 | selectedId: 'simon xiong' 237 | }); 238 | 239 | // We'll use this as a sub-query match. 240 | global.Date.now = jest.fn(() => now - 1 * hour); 241 | frecency.save({ 242 | searchQuery: 'brad', 243 | selectedId: 'brad neuberg' 244 | }); 245 | 246 | // We'll use this as an ID match. 247 | global.Date.now = jest.fn(() => now - 1 * hour); 248 | frecency.save({ 249 | searchQuery: 'vogel', 250 | selectedId: 'brad vogel' 251 | }); 252 | 253 | global.Date.now = jest.fn(() => now); 254 | 255 | const results = frecency.sort({ 256 | searchQuery: 'br', 257 | results: [{ 258 | _id: 'brad vogel' 259 | }, { 260 | _id: 'simon xiong' 261 | }, { 262 | _id: 'brad neuberg' 263 | }, { 264 | _id: 'other' 265 | }] 266 | }); 267 | 268 | expect(results).toEqual([{ 269 | _id: 'simon xiong' 270 | }, { 271 | _id: 'brad neuberg' 272 | }, { 273 | _id: 'brad vogel' 274 | }, { 275 | _id: 'other' 276 | }]); 277 | }); 278 | 279 | it('supports different ID attribute.', () => { 280 | const frecency = new Frecency({ 281 | key: 'templates', 282 | idAttribute: 'email' 283 | }); 284 | 285 | const now = 1524085045510; 286 | 287 | global.Date.now = jest.fn(() => now - 1 * hour); 288 | frecency.save({ 289 | searchQuery: 'sim', 290 | selectedId: 'simon@mixmax.com' 291 | }); 292 | 293 | global.Date.now = jest.fn(() => now); 294 | 295 | const results = frecency.sort({ 296 | searchQuery: 'br', 297 | results: [{ 298 | _id: 'simon@mixmax.com', 299 | email: 'other@mixmax.com' 300 | }, { 301 | _id: 'not simon', 302 | email: 'simon@mixmax.com' 303 | }] 304 | }); 305 | 306 | expect(results).toEqual([{ 307 | _id: 'not simon', 308 | email: 'simon@mixmax.com' 309 | }, { 310 | _id: 'simon@mixmax.com', 311 | email: 'other@mixmax.com' 312 | }]); 313 | }); 314 | 315 | it('supports unified search using ID attribute function.', () => { 316 | const frecency = new Frecency({ 317 | key: 'templates', 318 | idAttribute: (result) => { 319 | // Results are a mix of email contacts or contact groups. 320 | return result.email || result.groupName; 321 | } 322 | }); 323 | 324 | const now = 1524085045510; 325 | 326 | global.Date.now = jest.fn(() => now - 1 * day); 327 | frecency.save({ 328 | searchQuery: 'sim', 329 | selectedId: 'simon@mixmax.com' 330 | }); 331 | 332 | global.Date.now = jest.fn(() => now - 1 * hour); 333 | frecency.save({ 334 | searchQuery: 'personal', 335 | selectedId: 'personal contact group' 336 | }); 337 | 338 | global.Date.now = jest.fn(() => now); 339 | 340 | const results = frecency.sort({ 341 | searchQuery: 'per', 342 | results: [{ 343 | email: 'brad@mixmax.com' 344 | }, { 345 | groupName: 'everyone' 346 | }, { 347 | email: 'simon@mixmax.com' 348 | }, { 349 | groupName: 'personal contact group' 350 | }, { 351 | groupName: 'testing group' 352 | }] 353 | }); 354 | 355 | expect(results).toEqual([{ 356 | groupName: 'personal contact group' 357 | }, { 358 | email: 'simon@mixmax.com' 359 | }, { 360 | email: 'brad@mixmax.com' 361 | }, { 362 | groupName: 'everyone' 363 | }, { 364 | groupName: 'testing group' 365 | }]); 366 | }); 367 | 368 | it('fallbacks on subquery matching when query entry is too old (> 14 days)', () => { 369 | const frecency = new Frecency({ key: 'templates' }); 370 | const now = 1524085045510; 371 | 372 | global.Date.now = jest.fn(() => now - 15 * day); 373 | frecency.save({ 374 | searchQuery: 'br', 375 | selectedId: 'brad neuberg' 376 | }); 377 | 378 | global.Date.now = jest.fn(() => now - 2 * day); 379 | frecency.save({ 380 | searchQuery: 'brad', 381 | selectedId: 'brad neuberg' 382 | }); 383 | 384 | global.Date.now = jest.fn(() => now); 385 | 386 | const results = frecency.sort({ 387 | searchQuery: 'br', 388 | results: [{ 389 | _id: 'brad vogel' 390 | }, { 391 | _id: 'simon xiong' 392 | }, { 393 | _id: 'brad neuberg' 394 | }] 395 | }); 396 | 397 | expect(results).toEqual([{ 398 | _id: 'brad neuberg' 399 | }, { 400 | _id: 'brad vogel' 401 | }, { 402 | _id: 'simon xiong' 403 | }]); 404 | }); 405 | 406 | 407 | it('fallbacks on recent selections matching when queries/subqueries are too old (> 14 days)', () => { 408 | const frecency = new Frecency({ key: 'templates' }); 409 | const now = 1524085045510; 410 | 411 | global.Date.now = jest.fn(() => now - 15 * day); 412 | frecency.save({ 413 | searchQuery: 'brad', 414 | selectedId: 'brad neuberg' 415 | }); 416 | 417 | global.Date.now = jest.fn(() => now - 2 * day); 418 | frecency.save({ 419 | searchQuery: 'bra', 420 | selectedId: 'brad neuberg' 421 | }); 422 | 423 | global.Date.now = jest.fn(() => now); 424 | 425 | const results = frecency.sort({ 426 | searchQuery: 'brad', 427 | results: [{ 428 | _id: 'brad vogel' 429 | }, { 430 | _id: 'simon xiong' 431 | }, { 432 | _id: 'brad neuberg' 433 | }] 434 | }); 435 | 436 | expect(results).toEqual([{ 437 | _id: 'brad neuberg' 438 | }, { 439 | _id: 'brad vogel' 440 | }, { 441 | _id: 'simon xiong' 442 | }]); 443 | }); 444 | }); 445 | }); 446 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | import type { FrecencyData, FrecencyOptions, SaveParams, SortParams, StorageProvider } from './types'; 3 | import { 4 | isSubQuery, 5 | loadStorageProvider, 6 | } from './utils'; 7 | 8 | class Frecency { 9 | // Used to create key that will be used to save frecency data in localStorage. 10 | _key: string; 11 | // Max number of timestamps to save for recent selections of a result. 12 | _timestampsLimit: number; 13 | // Max number of IDs that should be stored in frecency to limit the object size. 14 | _recentSelectionsLimit: number; 15 | // Attribute to use as the search result's id. 16 | _idAttribute: string | Function; 17 | 18 | _localStorageEnabled: boolean; 19 | _storageProvider: ?StorageProvider; 20 | _frecency: FrecencyData; 21 | 22 | _exactQueryMatchWeight: number; 23 | _subQueryMatchWeight: number; 24 | _recentSelectionsMatchWeight: number; 25 | 26 | constructor({ key, timestampsLimit, recentSelectionsLimit, idAttribute, storageProvider, exactQueryMatchWeight, subQueryMatchWeight, recentSelectionsMatchWeight }: FrecencyOptions) { 27 | if (!key) throw new Error('key is required.'); 28 | 29 | this._key = key; 30 | this._timestampsLimit = timestampsLimit || 10; 31 | this._recentSelectionsLimit = recentSelectionsLimit || 100; 32 | this._idAttribute = idAttribute || '_id'; 33 | this._storageProvider = loadStorageProvider(storageProvider); 34 | this._localStorageEnabled = Boolean(this._storageProvider); 35 | this._exactQueryMatchWeight = exactQueryMatchWeight || 1.0; 36 | this._subQueryMatchWeight = subQueryMatchWeight || 0.7; 37 | this._recentSelectionsMatchWeight = recentSelectionsMatchWeight || 0.5; 38 | 39 | this._frecency = this._getFrecencyData(); 40 | } 41 | 42 | /** 43 | * Updates frecency data after user selects a result. 44 | * @param {Object} params 45 | * @prop {String} searchQuery - The search query the user entered. 46 | * @prop {String} selectedId - String representing the ID of the search result selected. 47 | */ 48 | save({ searchQuery, selectedId }: SaveParams): void { 49 | if (!selectedId || !this._localStorageEnabled) return; 50 | 51 | const now = Date.now(); 52 | 53 | // Reload frecency here to pick up frecency updates from other tabs. 54 | const frecency = this._getFrecencyData(); 55 | 56 | // Associate the selection with the search query used. This lets us sort this 57 | // selection higher when the user enters the search query again. See: 58 | // https://slack.engineering/a-faster-smarter-quick-switcher-77cbc193cb60#80de 59 | this._updateFrecencyByQuery(frecency, searchQuery, selectedId, now); 60 | 61 | // Associate the selection with its ID. If the user doesn't enter the same search 62 | // query as before, but this selection shows up in the list of results, we still 63 | // want this selection to show up higher because it was recently selected. See: 64 | // https://slack.engineering/a-faster-smarter-quick-switcher-77cbc193cb60#700c 65 | this._updateFrecencyById(frecency, searchQuery, selectedId, now); 66 | 67 | this._cleanUpOldIds(frecency, selectedId); 68 | this._saveFrecencyData(frecency); 69 | 70 | this._frecency = frecency; 71 | } 72 | 73 | // Returns the key that will be used to save frecency data in storage. 74 | _getFrecencyKey(): string { 75 | return `frecency_${this._key}`; 76 | } 77 | 78 | // Reads frecency data from storage and returns the frecency object if 79 | // the stored frecency data is valid. 80 | _getFrecencyData(): FrecencyData { 81 | const defaultFrecency: FrecencyData = { 82 | queries: {}, 83 | selections: {}, 84 | recentSelections: [] 85 | }; 86 | 87 | if (!this._localStorageEnabled || !this._storageProvider) return defaultFrecency; 88 | 89 | const savedData = this._storageProvider.getItem(this._getFrecencyKey()); 90 | if (!savedData) return defaultFrecency; 91 | 92 | try { 93 | return JSON.parse(savedData); 94 | } catch (e) { 95 | return defaultFrecency; 96 | } 97 | } 98 | 99 | /** 100 | * Save frecency data back to storage. 101 | * @param {FrecencyData} frecency 102 | */ 103 | _saveFrecencyData(frecency: FrecencyData): void { 104 | if (!this._localStorageEnabled || !this._storageProvider) return; 105 | 106 | this._storageProvider.setItem(this._getFrecencyKey(), JSON.stringify(frecency)); 107 | } 108 | 109 | /** 110 | * Updates frecency by the search query the user entered when selecting a result. 111 | * @param {FrecencyData} frecency - Frecency object to be modified in place. 112 | * @param {String} searchQuery - Search query the user entered. 113 | * @param {String} selectedId - ID of search result the user selected. 114 | * @param {Number} now - Current time in milliseconds. 115 | */ 116 | _updateFrecencyByQuery(frecency: FrecencyData, searchQuery: ?string, selectedId: string, 117 | now: number): void { 118 | if (!searchQuery) return; 119 | 120 | const queries = frecency.queries; 121 | if (!queries[searchQuery]) queries[searchQuery] = []; 122 | 123 | const previousSelection = queries[searchQuery].find((selection) => { 124 | return selection.id === selectedId; 125 | }); 126 | 127 | // If this ID was not selected previously for this search query, we'll 128 | // create a new entry. 129 | if (!previousSelection) { 130 | queries[searchQuery].push({ 131 | id: selectedId, 132 | timesSelected: 1, 133 | selectedAt: [now] 134 | }); 135 | return; 136 | } 137 | 138 | // Otherwise, increment the previous entry. 139 | previousSelection.timesSelected += 1; 140 | previousSelection.selectedAt.push(now); 141 | 142 | // Limit the selections timestamps. 143 | if (previousSelection.selectedAt.length > this._timestampsLimit) { 144 | previousSelection.selectedAt.shift(); 145 | } 146 | } 147 | 148 | /** 149 | * Updates frecency by the ID of the result selected. 150 | * @param {FrecencyData} frecency - Frecency object to be modified in place. 151 | * @param {String} searchQuery - Search query the user entered. 152 | * @param {String} selectedId - ID of search result the user selected. 153 | * @param {Number} now - Current time in milliseconds. 154 | */ 155 | _updateFrecencyById(frecency: FrecencyData, searchQuery: ?string, selectedId: string, 156 | now: number): void { 157 | 158 | const selections = frecency.selections; 159 | const previousSelection = selections[selectedId]; 160 | 161 | // If this ID was not selected previously, we'll create a new entry. 162 | if (!previousSelection) { 163 | selections[selectedId] = { 164 | timesSelected: 1, 165 | selectedAt: [now], 166 | queries: {} 167 | }; 168 | 169 | if (searchQuery) selections[selectedId].queries[searchQuery] = true; 170 | return; 171 | } 172 | 173 | // Otherwise, update the previous entry. 174 | previousSelection.timesSelected += 1; 175 | previousSelection.selectedAt.push(now); 176 | 177 | // Limit the selections timestamps. 178 | if (previousSelection.selectedAt.length > this._timestampsLimit) { 179 | previousSelection.selectedAt.shift(); 180 | } 181 | 182 | // Remember which search queries this result was selected for so we can 183 | // remove this result from frecency later when cleaning up. 184 | if (searchQuery) previousSelection.queries[searchQuery] = true; 185 | } 186 | 187 | /** 188 | * Remove the oldest IDs in the frecency data once the number of IDs 189 | * saved in frecency has exceeded the limit. 190 | * @param {FrecencyData} frecency - Frecency object to be modified in place. 191 | * @param {String} selectedId - ID of search result the user selected. 192 | */ 193 | _cleanUpOldIds(frecency: FrecencyData, selectedId: string): void { 194 | const recentSelections = frecency.recentSelections; 195 | 196 | // If frecency already contains the selected ID, shift it to the front. 197 | if (recentSelections.includes(selectedId)) { 198 | frecency.recentSelections = [ 199 | selectedId, 200 | ...recentSelections.filter((id) => id !== selectedId) 201 | ]; 202 | return; 203 | } 204 | 205 | // Otherwise add the selected ID to the front of the list. 206 | if (recentSelections.length < this._recentSelectionsLimit) { 207 | frecency.recentSelections = [ 208 | selectedId, 209 | ...recentSelections 210 | ]; 211 | return; 212 | } 213 | 214 | // If the number of recent selections has gone over the limit, we'll remove 215 | // the least recently used ID from the frecency data. 216 | const idToRemove = recentSelections.pop(); 217 | 218 | frecency.recentSelections = [ 219 | selectedId, 220 | ...recentSelections 221 | ]; 222 | 223 | const selectionById = frecency.selections[idToRemove]; 224 | if (!selectionById) return; 225 | delete frecency.selections[idToRemove]; 226 | 227 | Object.keys(selectionById.queries).forEach((query) => { 228 | frecency.queries[query] = frecency.queries[query].filter((selection) => { 229 | return selection.id !== idToRemove; 230 | }); 231 | 232 | if (frecency.queries[query].length === 0) { 233 | delete frecency.queries[query]; 234 | } 235 | }); 236 | } 237 | 238 | /** 239 | * Sorts a list of search results based on the saved frecency data. 240 | * @param {Object} params 241 | * @prop {String} searchQuery - The search query the user entered. 242 | * @prop {Object[]} results - The list of search results to sort. 243 | * @prop {boolean} keepScores - Keep the frecency score attached to each item. 244 | * @return {Object[]} Search results sorted by frecency. 245 | */ 246 | sort({ searchQuery, results, keepScores = false }: SortParams): Object[] { 247 | if (!this._localStorageEnabled) return results; 248 | this._calculateFrecencyScores(results, searchQuery); 249 | 250 | // For recent selections, sort by frecency. Otherwise, fall back to 251 | // server-side sorting. 252 | const recentSelections = results.filter((result) => result._frecencyScore > 0); 253 | const otherSelections = results.filter((result) => result._frecencyScore === 0); 254 | 255 | const sortedResults = [ 256 | ...recentSelections.sort((a, b) => b._frecencyScore - a._frecencyScore), 257 | ...otherSelections 258 | ]; 259 | 260 | if (keepScores) return sortedResults; 261 | 262 | return sortedResults.map((result) => { 263 | delete result._frecencyScore; 264 | return result; 265 | }); 266 | } 267 | 268 | /** 269 | * Returns the ID of a search result that will be used for sorting. 270 | * @param {Object} result - Search result to retrieve the ID from. 271 | * @return {String} The ID of the search result. 272 | */ 273 | _getId(result: Object): string { 274 | if (typeof this._idAttribute === 'function') { 275 | return this._idAttribute(result); 276 | } else { 277 | return result[this._idAttribute]; 278 | } 279 | } 280 | 281 | /** 282 | * Calculates frecency scores for each search results and saves the score 283 | * on the result object. 284 | * @param {Object[]} results - List of search results to calculate scores for. 285 | * @param {String} searchQuery - Search query the user entered. 286 | */ 287 | _calculateFrecencyScores(results: Object[], searchQuery: ?string): void { 288 | const now = Date.now(); 289 | 290 | results.forEach((result) => { 291 | const resultId = this._getId(result); 292 | 293 | // Try calculating frecency score by exact query match. 294 | const frecencyForQuery = searchQuery && this._frecency.queries[searchQuery]; 295 | 296 | if (frecencyForQuery) { 297 | const selection = frecencyForQuery.find((selection) => { 298 | return selection.id === resultId; 299 | }); 300 | 301 | if (selection) { 302 | result._frecencyScore = this._exactQueryMatchWeight * this._calculateScore(selection.selectedAt, 303 | selection.timesSelected, now); 304 | if (result._frecencyScore > 0) return; 305 | } 306 | } 307 | 308 | // Try calculating frecency score by sub-query match. 309 | const subQueries = Object.keys(this._frecency.queries).filter((query) => { 310 | return isSubQuery(searchQuery, query); 311 | }); 312 | 313 | // Use for-loop to allow early-return. 314 | for (const subQuery of subQueries) { 315 | const selection = this._frecency.queries[subQuery].find((selection) => { 316 | return selection.id === resultId; 317 | }); 318 | 319 | if (selection) { 320 | // Reduce the score because this is not an exact query match. 321 | result._frecencyScore = this._subQueryMatchWeight * this._calculateScore(selection.selectedAt, 322 | selection.timesSelected, now); 323 | if (result._frecencyScore > 0) return; 324 | } 325 | } 326 | 327 | // Try calculating frecency score by ID. 328 | const selection = this._frecency.selections[resultId]; 329 | if (selection) { 330 | // Reduce the score because this is not an exact query match. 331 | result._frecencyScore = this._recentSelectionsMatchWeight * this._calculateScore(selection.selectedAt, 332 | selection.timesSelected, now); 333 | return; 334 | } 335 | 336 | result._frecencyScore = 0; 337 | }); 338 | } 339 | 340 | /** 341 | * Calculates a frecency score based on the timestamps a resources was selected, 342 | * the total number of times selected, and the current time. 343 | * @param {Number[]} timestamps - Timestamps of recent selections. 344 | * @param {Number} timesSelected - Total number of times selected. 345 | * @param {Number} now - Current time in milliseconds. 346 | * @return {Number} The calculated frecency score. 347 | */ 348 | _calculateScore(timestamps: number[], timesSelected: number, now: number): number { 349 | if (timestamps.length === 0) return 0; 350 | 351 | const hour = 1000 * 60 * 60; 352 | const day = 24 * hour; 353 | 354 | const totalScore = timestamps.reduce((score, timestamp) => { 355 | if (timestamp >= now - 3 * hour) return score + 100; 356 | if (timestamp >= now - day) return score + 80; 357 | if (timestamp >= now - 3 * day) return score + 60; 358 | if (timestamp >= now - 7 * day) return score + 30; 359 | if (timestamp >= now - 14 * day) return score + 10; 360 | return score; 361 | }, 0); 362 | 363 | return timesSelected * (totalScore / timestamps.length); 364 | } 365 | } 366 | 367 | export default Frecency; 368 | -------------------------------------------------------------------------------- /flow-typed/npm/jest_v22.x.x.js: -------------------------------------------------------------------------------- 1 | // flow-typed signature: ce84f78f95836afb1ef53040f4906859 2 | // flow-typed version: ab9a73e70c/jest_v22.x.x/flow_>=v0.39.x 3 | 4 | type JestMockFn, TReturn> = { 5 | (...args: TArguments): TReturn, 6 | /** 7 | * An object for introspecting mock calls 8 | */ 9 | mock: { 10 | /** 11 | * An array that represents all calls that have been made into this mock 12 | * function. Each call is represented by an array of arguments that were 13 | * passed during the call. 14 | */ 15 | calls: Array, 16 | /** 17 | * An array that contains all the object instances that have been 18 | * instantiated from this mock function. 19 | */ 20 | instances: Array 21 | }, 22 | /** 23 | * Resets all information stored in the mockFn.mock.calls and 24 | * mockFn.mock.instances arrays. Often this is useful when you want to clean 25 | * up a mock's usage data between two assertions. 26 | */ 27 | mockClear(): void, 28 | /** 29 | * Resets all information stored in the mock. This is useful when you want to 30 | * completely restore a mock back to its initial state. 31 | */ 32 | mockReset(): void, 33 | /** 34 | * Removes the mock and restores the initial implementation. This is useful 35 | * when you want to mock functions in certain test cases and restore the 36 | * original implementation in others. Beware that mockFn.mockRestore only 37 | * works when mock was created with jest.spyOn. Thus you have to take care of 38 | * restoration yourself when manually assigning jest.fn(). 39 | */ 40 | mockRestore(): void, 41 | /** 42 | * Accepts a function that should be used as the implementation of the mock. 43 | * The mock itself will still record all calls that go into and instances 44 | * that come from itself -- the only difference is that the implementation 45 | * will also be executed when the mock is called. 46 | */ 47 | mockImplementation( 48 | fn: (...args: TArguments) => TReturn 49 | ): JestMockFn, 50 | /** 51 | * Accepts a function that will be used as an implementation of the mock for 52 | * one call to the mocked function. Can be chained so that multiple function 53 | * calls produce different results. 54 | */ 55 | mockImplementationOnce( 56 | fn: (...args: TArguments) => TReturn 57 | ): JestMockFn, 58 | /** 59 | * Accepts a string to use in test result output in place of "jest.fn()" to 60 | * indicate which mock function is being referenced. 61 | */ 62 | mockName(name: string): JestMockFn, 63 | /** 64 | * Just a simple sugar function for returning `this` 65 | */ 66 | mockReturnThis(): void, 67 | /** 68 | * Deprecated: use jest.fn(() => value) instead 69 | */ 70 | mockReturnValue(value: TReturn): JestMockFn, 71 | /** 72 | * Sugar for only returning a value once inside your mock 73 | */ 74 | mockReturnValueOnce(value: TReturn): JestMockFn 75 | }; 76 | 77 | type JestAsymmetricEqualityType = { 78 | /** 79 | * A custom Jasmine equality tester 80 | */ 81 | asymmetricMatch(value: mixed): boolean 82 | }; 83 | 84 | type JestCallsType = { 85 | allArgs(): mixed, 86 | all(): mixed, 87 | any(): boolean, 88 | count(): number, 89 | first(): mixed, 90 | mostRecent(): mixed, 91 | reset(): void 92 | }; 93 | 94 | type JestClockType = { 95 | install(): void, 96 | mockDate(date: Date): void, 97 | tick(milliseconds?: number): void, 98 | uninstall(): void 99 | }; 100 | 101 | type JestMatcherResult = { 102 | message?: string | (() => string), 103 | pass: boolean 104 | }; 105 | 106 | type JestMatcher = (actual: any, expected: any) => JestMatcherResult; 107 | 108 | type JestPromiseType = { 109 | /** 110 | * Use rejects to unwrap the reason of a rejected promise so any other 111 | * matcher can be chained. If the promise is fulfilled the assertion fails. 112 | */ 113 | rejects: JestExpectType, 114 | /** 115 | * Use resolves to unwrap the value of a fulfilled promise so any other 116 | * matcher can be chained. If the promise is rejected the assertion fails. 117 | */ 118 | resolves: JestExpectType 119 | }; 120 | 121 | /** 122 | * Jest allows functions and classes to be used as test names in test() and 123 | * describe() 124 | */ 125 | type JestTestName = string | Function; 126 | 127 | /** 128 | * Plugin: jest-enzyme 129 | */ 130 | type EnzymeMatchersType = { 131 | toBeChecked(): void, 132 | toBeDisabled(): void, 133 | toBeEmpty(): void, 134 | toBeEmptyRender(): void, 135 | toBePresent(): void, 136 | toContainReact(element: React$Element): void, 137 | toExist(): void, 138 | toHaveClassName(className: string): void, 139 | toHaveHTML(html: string): void, 140 | toHaveProp: ((propKey: string, propValue?: any) => void) & ((props: Object) => void), 141 | toHaveRef(refName: string): void, 142 | toHaveState: ((stateKey: string, stateValue?: any) => void) & ((state: Object) => void), 143 | toHaveStyle: ((styleKey: string, styleValue?: any) => void) & ((style: Object) => void), 144 | toHaveTagName(tagName: string): void, 145 | toHaveText(text: string): void, 146 | toIncludeText(text: string): void, 147 | toHaveValue(value: any): void, 148 | toMatchElement(element: React$Element): void, 149 | toMatchSelector(selector: string): void 150 | }; 151 | 152 | type JestExpectType = { 153 | not: JestExpectType & EnzymeMatchersType, 154 | /** 155 | * If you have a mock function, you can use .lastCalledWith to test what 156 | * arguments it was last called with. 157 | */ 158 | lastCalledWith(...args: Array): void, 159 | /** 160 | * toBe just checks that a value is what you expect. It uses === to check 161 | * strict equality. 162 | */ 163 | toBe(value: any): void, 164 | /** 165 | * Use .toHaveBeenCalled to ensure that a mock function got called. 166 | */ 167 | toBeCalled(): void, 168 | /** 169 | * Use .toBeCalledWith to ensure that a mock function was called with 170 | * specific arguments. 171 | */ 172 | toBeCalledWith(...args: Array): void, 173 | /** 174 | * Using exact equality with floating point numbers is a bad idea. Rounding 175 | * means that intuitive things fail. 176 | */ 177 | toBeCloseTo(num: number, delta: any): void, 178 | /** 179 | * Use .toBeDefined to check that a variable is not undefined. 180 | */ 181 | toBeDefined(): void, 182 | /** 183 | * Use .toBeFalsy when you don't care what a value is, you just want to 184 | * ensure a value is false in a boolean context. 185 | */ 186 | toBeFalsy(): void, 187 | /** 188 | * To compare floating point numbers, you can use toBeGreaterThan. 189 | */ 190 | toBeGreaterThan(number: number): void, 191 | /** 192 | * To compare floating point numbers, you can use toBeGreaterThanOrEqual. 193 | */ 194 | toBeGreaterThanOrEqual(number: number): void, 195 | /** 196 | * To compare floating point numbers, you can use toBeLessThan. 197 | */ 198 | toBeLessThan(number: number): void, 199 | /** 200 | * To compare floating point numbers, you can use toBeLessThanOrEqual. 201 | */ 202 | toBeLessThanOrEqual(number: number): void, 203 | /** 204 | * Use .toBeInstanceOf(Class) to check that an object is an instance of a 205 | * class. 206 | */ 207 | toBeInstanceOf(cls: Class<*>): void, 208 | /** 209 | * .toBeNull() is the same as .toBe(null) but the error messages are a bit 210 | * nicer. 211 | */ 212 | toBeNull(): void, 213 | /** 214 | * Use .toBeTruthy when you don't care what a value is, you just want to 215 | * ensure a value is true in a boolean context. 216 | */ 217 | toBeTruthy(): void, 218 | /** 219 | * Use .toBeUndefined to check that a variable is undefined. 220 | */ 221 | toBeUndefined(): void, 222 | /** 223 | * Use .toContain when you want to check that an item is in a list. For 224 | * testing the items in the list, this uses ===, a strict equality check. 225 | */ 226 | toContain(item: any): void, 227 | /** 228 | * Use .toContainEqual when you want to check that an item is in a list. For 229 | * testing the items in the list, this matcher recursively checks the 230 | * equality of all fields, rather than checking for object identity. 231 | */ 232 | toContainEqual(item: any): void, 233 | /** 234 | * Use .toEqual when you want to check that two objects have the same value. 235 | * This matcher recursively checks the equality of all fields, rather than 236 | * checking for object identity. 237 | */ 238 | toEqual(value: any): void, 239 | /** 240 | * Use .toHaveBeenCalled to ensure that a mock function got called. 241 | */ 242 | toHaveBeenCalled(): void, 243 | /** 244 | * Use .toHaveBeenCalledTimes to ensure that a mock function got called exact 245 | * number of times. 246 | */ 247 | toHaveBeenCalledTimes(number: number): void, 248 | /** 249 | * Use .toHaveBeenCalledWith to ensure that a mock function was called with 250 | * specific arguments. 251 | */ 252 | toHaveBeenCalledWith(...args: Array): void, 253 | /** 254 | * Use .toHaveBeenLastCalledWith to ensure that a mock function was last called 255 | * with specific arguments. 256 | */ 257 | toHaveBeenLastCalledWith(...args: Array): void, 258 | /** 259 | * Check that an object has a .length property and it is set to a certain 260 | * numeric value. 261 | */ 262 | toHaveLength(number: number): void, 263 | /** 264 | * 265 | */ 266 | toHaveProperty(propPath: string, value?: any): void, 267 | /** 268 | * Use .toMatch to check that a string matches a regular expression or string. 269 | */ 270 | toMatch(regexpOrString: RegExp | string): void, 271 | /** 272 | * Use .toMatchObject to check that a javascript object matches a subset of the properties of an object. 273 | */ 274 | toMatchObject(object: Object | Array): void, 275 | /** 276 | * This ensures that a React component matches the most recent snapshot. 277 | */ 278 | toMatchSnapshot(name?: string): void, 279 | /** 280 | * Use .toThrow to test that a function throws when it is called. 281 | * If you want to test that a specific error gets thrown, you can provide an 282 | * argument to toThrow. The argument can be a string for the error message, 283 | * a class for the error, or a regex that should match the error. 284 | * 285 | * Alias: .toThrowError 286 | */ 287 | toThrow(message?: string | Error | Class | RegExp): void, 288 | toThrowError(message?: string | Error | Class | RegExp): void, 289 | /** 290 | * Use .toThrowErrorMatchingSnapshot to test that a function throws a error 291 | * matching the most recent snapshot when it is called. 292 | */ 293 | toThrowErrorMatchingSnapshot(): void 294 | }; 295 | 296 | type JestObjectType = { 297 | /** 298 | * Disables automatic mocking in the module loader. 299 | * 300 | * After this method is called, all `require()`s will return the real 301 | * versions of each module (rather than a mocked version). 302 | */ 303 | disableAutomock(): JestObjectType, 304 | /** 305 | * An un-hoisted version of disableAutomock 306 | */ 307 | autoMockOff(): JestObjectType, 308 | /** 309 | * Enables automatic mocking in the module loader. 310 | */ 311 | enableAutomock(): JestObjectType, 312 | /** 313 | * An un-hoisted version of enableAutomock 314 | */ 315 | autoMockOn(): JestObjectType, 316 | /** 317 | * Clears the mock.calls and mock.instances properties of all mocks. 318 | * Equivalent to calling .mockClear() on every mocked function. 319 | */ 320 | clearAllMocks(): JestObjectType, 321 | /** 322 | * Resets the state of all mocks. Equivalent to calling .mockReset() on every 323 | * mocked function. 324 | */ 325 | resetAllMocks(): JestObjectType, 326 | /** 327 | * Restores all mocks back to their original value. 328 | */ 329 | restoreAllMocks(): JestObjectType, 330 | /** 331 | * Removes any pending timers from the timer system. 332 | */ 333 | clearAllTimers(): void, 334 | /** 335 | * The same as `mock` but not moved to the top of the expectation by 336 | * babel-jest. 337 | */ 338 | doMock(moduleName: string, moduleFactory?: any): JestObjectType, 339 | /** 340 | * The same as `unmock` but not moved to the top of the expectation by 341 | * babel-jest. 342 | */ 343 | dontMock(moduleName: string): JestObjectType, 344 | /** 345 | * Returns a new, unused mock function. Optionally takes a mock 346 | * implementation. 347 | */ 348 | fn, TReturn>( 349 | implementation?: (...args: TArguments) => TReturn 350 | ): JestMockFn, 351 | /** 352 | * Determines if the given function is a mocked function. 353 | */ 354 | isMockFunction(fn: Function): boolean, 355 | /** 356 | * Given the name of a module, use the automatic mocking system to generate a 357 | * mocked version of the module for you. 358 | */ 359 | genMockFromModule(moduleName: string): any, 360 | /** 361 | * Mocks a module with an auto-mocked version when it is being required. 362 | * 363 | * The second argument can be used to specify an explicit module factory that 364 | * is being run instead of using Jest's automocking feature. 365 | * 366 | * The third argument can be used to create virtual mocks -- mocks of modules 367 | * that don't exist anywhere in the system. 368 | */ 369 | mock( 370 | moduleName: string, 371 | moduleFactory?: any, 372 | options?: Object 373 | ): JestObjectType, 374 | /** 375 | * Returns the actual module instead of a mock, bypassing all checks on 376 | * whether the module should receive a mock implementation or not. 377 | */ 378 | requireActual(moduleName: string): any, 379 | /** 380 | * Returns a mock module instead of the actual module, bypassing all checks 381 | * on whether the module should be required normally or not. 382 | */ 383 | requireMock(moduleName: string): any, 384 | /** 385 | * Resets the module registry - the cache of all required modules. This is 386 | * useful to isolate modules where local state might conflict between tests. 387 | */ 388 | resetModules(): JestObjectType, 389 | /** 390 | * Exhausts the micro-task queue (usually interfaced in node via 391 | * process.nextTick). 392 | */ 393 | runAllTicks(): void, 394 | /** 395 | * Exhausts the macro-task queue (i.e., all tasks queued by setTimeout(), 396 | * setInterval(), and setImmediate()). 397 | */ 398 | runAllTimers(): void, 399 | /** 400 | * Exhausts all tasks queued by setImmediate(). 401 | */ 402 | runAllImmediates(): void, 403 | /** 404 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 405 | * or setInterval() and setImmediate()). 406 | */ 407 | advanceTimersByTime(msToRun: number): void, 408 | /** 409 | * Executes only the macro task queue (i.e. all tasks queued by setTimeout() 410 | * or setInterval() and setImmediate()). 411 | * 412 | * Renamed to `advanceTimersByTime`. 413 | */ 414 | runTimersToTime(msToRun: number): void, 415 | /** 416 | * Executes only the macro-tasks that are currently pending (i.e., only the 417 | * tasks that have been queued by setTimeout() or setInterval() up to this 418 | * point) 419 | */ 420 | runOnlyPendingTimers(): void, 421 | /** 422 | * Explicitly supplies the mock object that the module system should return 423 | * for the specified module. Note: It is recommended to use jest.mock() 424 | * instead. 425 | */ 426 | setMock(moduleName: string, moduleExports: any): JestObjectType, 427 | /** 428 | * Indicates that the module system should never return a mocked version of 429 | * the specified module from require() (e.g. that it should always return the 430 | * real module). 431 | */ 432 | unmock(moduleName: string): JestObjectType, 433 | /** 434 | * Instructs Jest to use fake versions of the standard timer functions 435 | * (setTimeout, setInterval, clearTimeout, clearInterval, nextTick, 436 | * setImmediate and clearImmediate). 437 | */ 438 | useFakeTimers(): JestObjectType, 439 | /** 440 | * Instructs Jest to use the real versions of the standard timer functions. 441 | */ 442 | useRealTimers(): JestObjectType, 443 | /** 444 | * Creates a mock function similar to jest.fn but also tracks calls to 445 | * object[methodName]. 446 | */ 447 | spyOn(object: Object, methodName: string): JestMockFn, 448 | /** 449 | * Set the default timeout interval for tests and before/after hooks in milliseconds. 450 | * Note: The default timeout interval is 5 seconds if this method is not called. 451 | */ 452 | setTimeout(timeout: number): JestObjectType 453 | }; 454 | 455 | type JestSpyType = { 456 | calls: JestCallsType 457 | }; 458 | 459 | /** Runs this function after every test inside this context */ 460 | declare function afterEach( 461 | fn: (done: () => void) => ?Promise, 462 | timeout?: number 463 | ): void; 464 | /** Runs this function before every test inside this context */ 465 | declare function beforeEach( 466 | fn: (done: () => void) => ?Promise, 467 | timeout?: number 468 | ): void; 469 | /** Runs this function after all tests have finished inside this context */ 470 | declare function afterAll( 471 | fn: (done: () => void) => ?Promise, 472 | timeout?: number 473 | ): void; 474 | /** Runs this function before any tests have started inside this context */ 475 | declare function beforeAll( 476 | fn: (done: () => void) => ?Promise, 477 | timeout?: number 478 | ): void; 479 | 480 | /** A context for grouping tests together */ 481 | declare var describe: { 482 | /** 483 | * Creates a block that groups together several related tests in one "test suite" 484 | */ 485 | (name: JestTestName, fn: () => void): void, 486 | 487 | /** 488 | * Only run this describe block 489 | */ 490 | only(name: JestTestName, fn: () => void): void, 491 | 492 | /** 493 | * Skip running this describe block 494 | */ 495 | skip(name: JestTestName, fn: () => void): void 496 | }; 497 | 498 | /** An individual test unit */ 499 | declare var it: { 500 | /** 501 | * An individual test unit 502 | * 503 | * @param {JestTestName} Name of Test 504 | * @param {Function} Test 505 | * @param {number} Timeout for the test, in milliseconds. 506 | */ 507 | ( 508 | name: JestTestName, 509 | fn?: (done: () => void) => ?Promise, 510 | timeout?: number 511 | ): void, 512 | /** 513 | * Only run this test 514 | * 515 | * @param {JestTestName} Name of Test 516 | * @param {Function} Test 517 | * @param {number} Timeout for the test, in milliseconds. 518 | */ 519 | only( 520 | name: JestTestName, 521 | fn?: (done: () => void) => ?Promise, 522 | timeout?: number 523 | ): void, 524 | /** 525 | * Skip running this test 526 | * 527 | * @param {JestTestName} Name of Test 528 | * @param {Function} Test 529 | * @param {number} Timeout for the test, in milliseconds. 530 | */ 531 | skip( 532 | name: JestTestName, 533 | fn?: (done: () => void) => ?Promise, 534 | timeout?: number 535 | ): void, 536 | /** 537 | * Run the test concurrently 538 | * 539 | * @param {JestTestName} Name of Test 540 | * @param {Function} Test 541 | * @param {number} Timeout for the test, in milliseconds. 542 | */ 543 | concurrent( 544 | name: JestTestName, 545 | fn?: (done: () => void) => ?Promise, 546 | timeout?: number 547 | ): void 548 | }; 549 | declare function fit( 550 | name: JestTestName, 551 | fn: (done: () => void) => ?Promise, 552 | timeout?: number 553 | ): void; 554 | /** An individual test unit */ 555 | declare var test: typeof it; 556 | /** A disabled group of tests */ 557 | declare var xdescribe: typeof describe; 558 | /** A focused group of tests */ 559 | declare var fdescribe: typeof describe; 560 | /** A disabled individual test */ 561 | declare var xit: typeof it; 562 | /** A disabled individual test */ 563 | declare var xtest: typeof it; 564 | 565 | /** The expect function is used every time you want to test a value */ 566 | declare var expect: { 567 | /** The object that you want to make assertions against */ 568 | (value: any): JestExpectType & JestPromiseType & EnzymeMatchersType, 569 | /** Add additional Jasmine matchers to Jest's roster */ 570 | extend(matchers: { [name: string]: JestMatcher }): void, 571 | /** Add a module that formats application-specific data structures. */ 572 | addSnapshotSerializer(serializer: (input: Object) => string): void, 573 | assertions(expectedAssertions: number): void, 574 | hasAssertions(): void, 575 | any(value: mixed): JestAsymmetricEqualityType, 576 | anything(): any, 577 | arrayContaining(value: Array): Array, 578 | objectContaining(value: Object): Object, 579 | /** Matches any received string that contains the exact expected string. */ 580 | stringContaining(value: string): string, 581 | stringMatching(value: string | RegExp): string 582 | }; 583 | 584 | // TODO handle return type 585 | // http://jasmine.github.io/2.4/introduction.html#section-Spies 586 | declare function spyOn(value: mixed, method: string): Object; 587 | 588 | /** Holds all functions related to manipulating test runner */ 589 | declare var jest: JestObjectType; 590 | 591 | /** 592 | * The global Jasmine object, this is generally not exposed as the public API, 593 | * using features inside here could break in later versions of Jest. 594 | */ 595 | declare var jasmine: { 596 | DEFAULT_TIMEOUT_INTERVAL: number, 597 | any(value: mixed): JestAsymmetricEqualityType, 598 | anything(): any, 599 | arrayContaining(value: Array): Array, 600 | clock(): JestClockType, 601 | createSpy(name: string): JestSpyType, 602 | createSpyObj( 603 | baseName: string, 604 | methodNames: Array 605 | ): { [methodName: string]: JestSpyType }, 606 | objectContaining(value: Object): Object, 607 | stringMatching(value: string): string 608 | }; 609 | --------------------------------------------------------------------------------