├── .eslintrc ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.3.1.cjs ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.json ├── class-utils.js ├── computed.js ├── docs ├── .vitepress │ └── config.js ├── collection.md ├── events.md ├── get-started.md ├── index.md ├── introduction.md └── model.md ├── dom-utils.js ├── form.js ├── jsconfig.json ├── localstorage.js ├── nextbone.js ├── package.json ├── test ├── class-utils │ └── asyncmethod.js ├── computed │ └── computed.js ├── core │ ├── .eslintrc │ ├── collection.js │ ├── delegate.js │ ├── events.js │ ├── model.js │ ├── observable.js │ ├── router.js │ ├── sync.js │ └── view.js ├── fetch │ ├── fetch.js │ └── setup.js ├── form │ └── form.js ├── localstorage │ └── localstorage.js ├── validation │ ├── attributesOption.cjs │ ├── customCallbacks.cjs │ ├── customPatterns.cjs │ ├── customValidators.cjs │ ├── errorMessages.cjs │ ├── events.cjs │ ├── general.cjs │ ├── isValid.cjs │ ├── labelFormatter.cjs │ ├── mixin.cjs │ ├── nestedValidation.cjs │ ├── preValidate.cjs │ ├── setup-globals.js │ └── validators │ │ ├── acceptance.cjs │ │ ├── equalTo.cjs │ │ ├── length.cjs │ │ ├── max.cjs │ │ ├── maxLength.cjs │ │ ├── method.cjs │ │ ├── min.cjs │ │ ├── minLength.cjs │ │ ├── namedMethod.cjs │ │ ├── oneOf.cjs │ │ ├── pattern.cjs │ │ ├── patterns.cjs │ │ ├── range.cjs │ │ ├── rangeLength.cjs │ │ └── required.cjs └── virtualcollection │ ├── virtualcollection.js │ └── virtualstate.js ├── tools ├── backbonejs.html ├── migrate-docs.cjs └── qunit-to-wtr.js ├── tsconfig.types.json ├── types ├── class-utils.d.ts ├── class-utils.d.ts.map ├── computed.d.ts ├── computed.d.ts.map ├── dom-utils.d.ts ├── dom-utils.d.ts.map ├── form.d.ts ├── form.d.ts.map ├── localstorage.d.ts ├── localstorage.d.ts.map ├── nextbone.d.ts ├── utils.d.ts ├── utils.d.ts.map ├── validation.d.ts ├── validation.d.ts.map ├── virtualcollection.d.ts └── virtualcollection.d.ts.map ├── utils.js ├── validation.js ├── virtualcollection.js ├── web-test-runner.config.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "prettier" 4 | ], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "es6": true 9 | }, 10 | "parser": "@babel/eslint-parser", 11 | "parserOptions": { 12 | "ecmaVersion": 6 13 | }, 14 | "overrides": [ 15 | { 16 | "files": [ 17 | "test/core-next/**/*.js", 18 | "test/form/**/*.js", 19 | "test/localstorage/**/*.js", 20 | "test/virtualcollection/**/*.js", 21 | "test/fetch/**/*.js", 22 | "test/computed/**/*.js", 23 | "test/class-utils/**/*.js" 24 | ], 25 | "env": { 26 | "es2020": true, 27 | "mocha": true 28 | }, 29 | "rules": { 30 | "no-unused-expressions": "off" 31 | } 32 | } 33 | ], 34 | "globals": { 35 | "attachEvent": false, 36 | "detachEvent": false 37 | }, 38 | "rules": { 39 | "array-bracket-spacing": 2, 40 | "block-scoped-var": 2, 41 | "brace-style": [ 42 | 1, 43 | "1tbs", 44 | { 45 | "allowSingleLine": true 46 | } 47 | ], 48 | "camelcase": 2, 49 | "comma-dangle": [ 50 | 2, 51 | "never" 52 | ], 53 | "comma-spacing": 2, 54 | "computed-property-spacing": [ 55 | 2, 56 | "never" 57 | ], 58 | "dot-notation": [ 59 | 2, 60 | { 61 | "allowKeywords": false 62 | } 63 | ], 64 | "eol-last": 2, 65 | "eqeqeq": [ 66 | 2, 67 | "smart" 68 | ], 69 | "key-spacing": 1, 70 | "keyword-spacing": [ 71 | 2, 72 | { 73 | "after": true 74 | } 75 | ], 76 | "max-depth": [ 77 | 1, 78 | 4 79 | ], 80 | "max-params": [ 81 | 1, 82 | 5 83 | ], 84 | "new-cap": [ 85 | 2, 86 | { 87 | "newIsCapExceptions": [ 88 | "model", 89 | "modelClass" 90 | ] 91 | } 92 | ], 93 | "no-alert": 2, 94 | "no-caller": 2, 95 | "no-catch-shadow": 2, 96 | "no-console": 2, 97 | "no-debugger": 2, 98 | "no-delete-var": 2, 99 | "no-div-regex": 1, 100 | "no-dupe-args": 2, 101 | "no-dupe-keys": 2, 102 | "no-duplicate-case": 2, 103 | "no-else-return": 1, 104 | "no-empty-character-class": 2, 105 | "no-eval": 2, 106 | "no-ex-assign": 2, 107 | "no-extend-native": 2, 108 | "no-extra-boolean-cast": 2, 109 | "no-extra-semi": 2, 110 | "no-fallthrough": 2, 111 | "no-floating-decimal": 2, 112 | "no-func-assign": 2, 113 | "no-implied-eval": 2, 114 | "no-inner-declarations": 2, 115 | "no-irregular-whitespace": 2, 116 | "no-label-var": 2, 117 | "no-labels": 2, 118 | "no-lone-blocks": 2, 119 | "no-lonely-if": 2, 120 | "no-multi-str": 2, 121 | "no-native-reassign": 2, 122 | "no-negated-in-lhs": 1, 123 | "no-new-object": 2, 124 | "no-new-wrappers": 2, 125 | "no-obj-calls": 2, 126 | "no-octal": 2, 127 | "no-octal-escape": 2, 128 | "no-proto": 2, 129 | "no-redeclare": 2, 130 | "no-shadow": 2, 131 | "no-spaced-func": 2, 132 | "no-throw-literal": 2, 133 | "no-trailing-spaces": 2, 134 | "no-undef": 2, 135 | "no-undef-init": 2, 136 | "no-unneeded-ternary": 2, 137 | "no-unreachable": 2, 138 | "no-unused-expressions": [ 139 | 2, 140 | { 141 | "allowTernary": true, 142 | "allowShortCircuit": true 143 | } 144 | ], 145 | "no-with": 2, 146 | "quotes": [ 147 | 2, 148 | "single", 149 | "avoid-escape" 150 | ], 151 | "radix": 2, 152 | "semi": 2, 153 | "space-before-function-paren": [ 154 | 2, 155 | { 156 | "anonymous": "never", 157 | "named": "never" 158 | } 159 | ], 160 | "space-infix-ops": 2, 161 | "space-unary-ops": [ 162 | 2, 163 | { 164 | "words": true, 165 | "nonwords": false 166 | } 167 | ], 168 | "use-isnan": 2, 169 | "valid-typeof": 2, 170 | "wrap-iife": [ 171 | 2, 172 | "inside" 173 | ] 174 | } 175 | } -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | pages: write 12 | id-token: write 13 | environment: 14 | name: github-pages 15 | url: ${{ steps.deployment.outputs.page_url }} 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 16 23 | cache: yarn 24 | - run: yarn install --frozen-lockfile 25 | - name: Build 26 | run: yarn docs:build 27 | - uses: actions/configure-pages@v2 28 | - uses: actions/upload-pages-artifact@v1 29 | with: 30 | path: docs/.vitepress/dist 31 | - name: Deploy 32 | id: deployment 33 | uses: actions/deploy-pages@v1 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | raw 2 | *.sw? 3 | .DS_Store 4 | node_modules 5 | bower_components 6 | 7 | docs/.vitepress/dist 8 | docs/.vitepress/cache 9 | 10 | .pnp.* 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/plugins 14 | !.yarn/releases 15 | !.yarn/sdks 16 | !.yarn/versions 17 | /tools/backbonejs.md 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "attach", 10 | "name": "Core, FormBind, LocalStorage Tests (Attach to Karma)", 11 | "address": "localhost", 12 | "port": 9333, 13 | "pathMapping": { 14 | "/": "${workspaceRoot}", 15 | "/base/": "${workspaceRoot}/" 16 | } 17 | }, 18 | { 19 | "type": "node", 20 | "request": "launch", 21 | "name": "Validation Tests", 22 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 23 | "args": [ 24 | "-u", 25 | "exports", 26 | "--require", 27 | "@babel/register", 28 | "--recursive", 29 | "${workspaceRoot}/test/validation" 30 | ], 31 | "env": { 32 | "NODE_ENV": "test" 33 | }, 34 | "internalConsoleOptions": "openOnSessionStart", 35 | "disableOptimisticBPs": true 36 | }, 37 | { 38 | "type": "node", 39 | "request": "launch", 40 | "name": "Service Tests", 41 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 42 | "args": ["--require", "@babel/register", "${workspaceRoot}/test/service"], 43 | "env": { 44 | "NODE_ENV": "test" 45 | }, 46 | "internalConsoleOptions": "openOnSessionStart", 47 | "disableOptimisticBPs": true 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.3.1.cjs 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to Open a Backbone.js Ticket 2 | 3 | * Do not use tickets to ask for help with (debugging) your application. Ask on 4 | the [mailing list](https://groups.google.com/forum/#!forum/backbonejs), 5 | in the IRC channel (`#documentcloud` on Freenode), or if you understand your 6 | specific problem, on [StackOverflow](http://stackoverflow.com/questions/tagged/backbone.js). 7 | 8 | * Before you open a ticket or send a pull request, 9 | [search](https://github.com/jashkenas/backbone/issues) for previous 10 | discussions about the same feature or issue. Add to the earlier ticket if you 11 | find one. 12 | 13 | * Before sending a pull request for a feature or bug fix, be sure to have 14 | [tests](http://backbonejs.org/test/). 15 | 16 | * Use the same coding style as the rest of the 17 | [codebase](https://github.com/jashkenas/backbone/blob/master/backbone.js). 18 | 19 | * In your pull request, do not add documentation or rebuild the minified 20 | `backbone-min.js` file. We'll do that before cutting a new release. 21 | 22 | * All pull requests should be made to the `master` branch. 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Luiz Américo Pereira Câmara 2 | Copyright (c) 2010-2017 Jeremy Ashkenas, DocumentCloud 3 | Copyright (c) 2012 Alexander Beletsky 4 | Copyright (c) 2015 Thedersen 5 | Copyright (c) 2010-2017 Jerome Gravel-Niquet 6 | Copyright (c) 2016 Adam Krebs 7 | 8 | Permission is hereby granted, free of charge, to any person 9 | obtaining a copy of this software and associated documentation 10 | files (the "Software"), to deal in the Software without 11 | restriction, including without limitation the rights to use, 12 | copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the 14 | Software is furnished to do so, subject to the following 15 | conditions: 16 | 17 | The above copyright notice and this permission notice shall be 18 | included in all copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 21 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 22 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 23 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 24 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 25 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 26 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 27 | OTHER DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nextbone 2 | 3 | Nextbone is a conversion of venerable [Backbone](http://backbonejs.org/) using modern Javascript features. It also replaces the View layer by a set of utilities to integrate with Web Components. 4 | 5 | ### Features 6 | 7 | - Keeps Backbone features / behavior with minimal changes. _In fact, most of the code is untouched_ 8 | - Uses EcmaScript Modules and Classes 9 | - Fully tree shackable 10 | - Seamless integration with Web Components (specially [LitElement](https://lit-element.polymer-project.org/)) 11 | 12 | ### Install 13 | 14 | $ npm install nextbone 15 | 16 | To take fully advantage of nextbone is necessary to use Typescript or Babel configured with `@babel/plugin-proposal-decorators` and `@babel/plugin-proposal-class-properties` plugins 17 | 18 | ### Usage 19 | 20 | > Examples uses language features (class properties and decorators) that needs transpiling with Babel or Typescript 21 | 22 | Define models 23 | 24 | ```Javascript 25 | import { Model, Collection } from 'nextbone' 26 | 27 | class Task extends Model { 28 | static defaults = { 29 | title: '', 30 | done: false 31 | } 32 | } 33 | 34 | class Tasks extends Collection { 35 | static model = Task 36 | } 37 | 38 | const tasks = new Tasks() 39 | tasks.fetch() 40 | ``` 41 | 42 | Define a web component using LitElement 43 | 44 | Without decorators 45 | 46 | ```Javascript 47 | import { LitElement, html} from 'lit' 48 | import { view, delegate } from 'nextbone' 49 | 50 | class TasksView extends view(LitElement) { 51 | static properties = { 52 | // set type hint to `Collection` or `Model` to enable update on property mutation 53 | tasks: { type: Collection } 54 | } 55 | 56 | constructor() { 57 | super() 58 | this.tasks = new Tasks() 59 | delegate(this, 'click', '#fetch', this.fetchTasks) 60 | } 61 | 62 | fetchTasks() { 63 | this.tasks.fetch() 64 | } 65 | 66 | render() { 67 | return html` 68 |

Tasks

69 | 74 | 75 | ` 76 | } 77 | } 78 | 79 | customElements.define('tasks-view', TasksView) 80 | 81 | document.body.innerHTML = '' 82 | ``` 83 | 84 | With decorators 85 | 86 | ```Javascript 87 | import { LitElement, html, property } from 'lit' 88 | import { state, eventHandler } from 'nextbone' 89 | 90 | @view 91 | class TasksView extends LitElement { 92 | // use specialized `state` decorator 93 | @state 94 | tasks = new Tasks() 95 | 96 | // or use `property` decorator with type hint = `Collection` or `Model` 97 | @property({ type: Collection }) 98 | tasks = new Tasks() 99 | 100 | @eventHandler('click', '#fetch') 101 | fetchTasks() { 102 | this.tasks.fetch() 103 | } 104 | 105 | render() { 106 | return html` 107 |

Tasks

108 | 113 | 114 | ` 115 | } 116 | } 117 | 118 | customElements.define('tasks-view', TasksView) 119 | 120 | document.body.innerHTML = '' 121 | ``` 122 | 123 | ### Documentation 124 | 125 | [WIP](https://blikblum.github.io/nextbone/) 126 | 127 | ### Related projects 128 | 129 | Copyright © 2019-2024 Luiz Américo Pereira Câmara 130 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "browsers": [ 8 | "chrome 100" 9 | ] 10 | }, 11 | "modules": false 12 | } 13 | ] 14 | ], 15 | "plugins": [ 16 | [ 17 | "@babel/plugin-proposal-decorators", 18 | { 19 | "legacy": false, 20 | "decoratorsBeforeExport": false 21 | } 22 | ], 23 | [ 24 | "@babel/plugin-proposal-class-properties", 25 | { 26 | "loose": true 27 | } 28 | ], 29 | [ 30 | "@babel/plugin-proposal-private-methods", 31 | { 32 | "loose": true 33 | } 34 | ] 35 | ], 36 | "env": { 37 | "test": { 38 | "presets": [ 39 | [ 40 | "@babel/preset-env", 41 | { 42 | "targets": { 43 | "browsers": [ 44 | "chrome 100" 45 | ] 46 | } 47 | } 48 | ] 49 | ] 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /class-utils.js: -------------------------------------------------------------------------------- 1 | const resolved = Promise.resolve(); 2 | 3 | const startPromiseMap = new WeakMap(); 4 | 5 | const getStartPromise = (instance, startMethod) => { 6 | let result = typeof startMethod === 'function' ? startPromiseMap.get(instance) : resolved; 7 | if (!result) { 8 | result = resolved.then(() => startMethod.call(instance)); 9 | startPromiseMap.set(instance, result); 10 | } 11 | return result; 12 | }; 13 | 14 | const createAsyncMethod = descriptor => { 15 | const method = descriptor.value; 16 | descriptor.value = function(...args) { 17 | const promise = getStartPromise(this, this.start).then(() => method.apply(this, args)); 18 | 19 | promise.catch(err => { 20 | typeof this.onError === 'function' && this.onError(err); 21 | }); 22 | 23 | return promise; 24 | }; 25 | }; 26 | 27 | export const asyncMethod = (protoOrDescriptor, methodName, propertyDescriptor) => { 28 | if (typeof methodName !== 'string') { 29 | // spec decorator 30 | createAsyncMethod(protoOrDescriptor.descriptor); 31 | return protoOrDescriptor; 32 | } 33 | createAsyncMethod(propertyDescriptor); 34 | }; 35 | 36 | export const defineAsyncMethods = (klass, methodNames) => { 37 | const proto = klass.prototype; 38 | methodNames.forEach(methodName => { 39 | const desc = Object.getOwnPropertyDescriptor(proto, methodName); 40 | asyncMethod(proto, methodName, desc); 41 | Object.defineProperty(proto, methodName, desc); 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /computed.js: -------------------------------------------------------------------------------- 1 | import { isEmpty, reduce, omit } from 'lodash-es'; 2 | 3 | /** 4 | * @import { Model } from './nextbone.js' 5 | */ 6 | 7 | /** 8 | * @callback ComputedFieldGet 9 | * @param {Record} fields 10 | * @returns {any} 11 | * 12 | * @typedef ComputedField 13 | * @property {string[]} depends 14 | * @property {ComputedFieldGet} get 15 | * @property {(value: any, fields: Record) => any} set 16 | * Not possible to use rest arams here: https://github.com/Microsoft/TypeScript/issues/15190 17 | * @typedef {[string, ComputedFieldGet]} ShortHandComputedField1 18 | * @typedef {[string, string, ComputedFieldGet]} ShortHandComputedField2 19 | * @typedef {[string, string, string, ComputedFieldGet]} ShortHandComputedField3 20 | * @typedef {[string, string, string, string, ComputedFieldGet]} ShortHandComputedField4 21 | * @typedef {[string, string, string, string, string, ComputedFieldGet]} ShortHandComputedField5 22 | * @typedef {[string, string, string, string, string, string, ComputedFieldGet]} ShortHandComputedField6 23 | * @typedef {ShortHandComputedField1 | ShortHandComputedField2 | ShortHandComputedField3 | ShortHandComputedField4 | ShortHandComputedField5 | ShortHandComputedField6} ShortHandComputedField 24 | * 25 | * @typedef {Record} ComputedDefs 26 | */ 27 | 28 | /** 29 | * @param { ComputedField } computedField 30 | * @param { Model } model 31 | * @returns 32 | */ 33 | const computeFieldValue = (computedField, model) => { 34 | if (computedField && computedField.get) { 35 | const values = getDependentValues(computedField.depends, model); 36 | return computedField.get.call(model, values); 37 | } 38 | }; 39 | 40 | const getDependentValues = (depends, model) => { 41 | if (!depends) return {}; 42 | return depends.reduce((memo, field) => { 43 | if (typeof field === 'string') { 44 | memo[field] = model.get(field); 45 | } 46 | return memo; 47 | }, {}); 48 | }; 49 | 50 | const createFieldFromArray = arr => { 51 | const depends = []; 52 | let get, set; 53 | arr.forEach(item => { 54 | switch (typeof item) { 55 | case 'string': 56 | depends.push(item); 57 | break; 58 | case 'function': 59 | if (!get) { 60 | get = item; 61 | } else { 62 | set = item; 63 | } 64 | break; 65 | 66 | default: 67 | break; 68 | } 69 | }); 70 | return { depends, get, set }; 71 | }; 72 | 73 | const createNormalizedOptions = options => { 74 | if (!options) return; 75 | const excludeFromJSON = reduce( 76 | options, 77 | (result, def, key) => { 78 | if (def.toJSON !== true) { 79 | result.push(key); 80 | } 81 | return result; 82 | }, 83 | [] 84 | ); 85 | 86 | const fields = []; 87 | for (let key in options) { 88 | const field = options[key]; 89 | if (Array.isArray(field)) { 90 | fields.push({ name: key, field: createFieldFromArray(field) }); 91 | } else if (field && (field.set || field.get)) { 92 | fields.push({ name: key, field: field }); 93 | } 94 | } 95 | 96 | return { excludeFromJSON, fields }; 97 | }; 98 | 99 | class ComputedFields { 100 | constructor(model, fields) { 101 | this.model = model; 102 | this._bindModelEvents(fields); 103 | } 104 | 105 | _bindModelEvents(fields) { 106 | fields.forEach(computedField => { 107 | const fieldName = computedField.name; 108 | const field = computedField.field; 109 | 110 | const updateComputed = () => { 111 | var value = computeFieldValue(field, this.model); 112 | this.model.set(fieldName, value, { __computedSkip: true }); 113 | }; 114 | 115 | const updateDependent = (model, value, options) => { 116 | if (options && options.__computedSkip) { 117 | return; 118 | } 119 | 120 | if (field.set) { 121 | const values = getDependentValues(field.depends, this.model); 122 | value = value || this.model.get(fieldName); 123 | 124 | field.set.call(this.model, value, values); 125 | this.model.set(values, options); 126 | } 127 | }; 128 | 129 | this.model.on('change:' + fieldName, updateDependent); 130 | 131 | if (field.depends) { 132 | field.depends.forEach(dependent => { 133 | if (typeof dependent === 'string') { 134 | this.model.on('change:' + dependent, updateComputed); 135 | } 136 | 137 | if (typeof dependent === 'function') { 138 | dependent.call(this.model, updateComputed); 139 | } 140 | }); 141 | } 142 | 143 | if (!isEmpty(this.model.attributes)) { 144 | updateComputed(); 145 | } 146 | }); 147 | } 148 | } 149 | 150 | const excludeFromJSONKey = Symbol('excludeFromJSON'); 151 | 152 | const getComputedOptions = ctor => { 153 | if (ctor.hasOwnProperty('__computedOptions')) { 154 | return ctor.__computedOptions; 155 | } 156 | return (ctor.__computedOptions = createNormalizedOptions(ctor.computed)); 157 | }; 158 | 159 | const createClass = ModelClass => { 160 | return class extends ModelClass { 161 | constructor(...args) { 162 | super(...args); 163 | const options = getComputedOptions(this.constructor); 164 | if (options) { 165 | this.computedFields = new ComputedFields(this, options.fields); 166 | if (options.excludeFromJSON.length) { 167 | this[excludeFromJSONKey] = options.excludeFromJSON; 168 | } 169 | } 170 | } 171 | 172 | toJSON(...args) { 173 | const result = super.toJSON(...args); 174 | const excludeFromJSON = this[excludeFromJSONKey]; 175 | if (!excludeFromJSON || (args[0] && args[0].computed)) { 176 | return result; 177 | } 178 | return omit(result, excludeFromJSON); 179 | } 180 | }; 181 | }; 182 | 183 | /** 184 | * @typedef ComputedStaticMixin 185 | * @property {ComputedDefs} computed 186 | */ 187 | 188 | /** 189 | * @template {typeof Model} BaseClass 190 | * @param {BaseClass} ctorOrDescriptor - Base model class 191 | * @returns {BaseClass & ComputedStaticMixin} 192 | */ 193 | function withComputed(ctorOrDescriptor) { 194 | if (typeof ctorOrDescriptor === 'function') { 195 | return createClass(ctorOrDescriptor); 196 | } 197 | const { kind, elements } = ctorOrDescriptor; 198 | return { 199 | kind, 200 | elements, 201 | finisher(ctor) { 202 | return createClass(ctor); 203 | } 204 | }; 205 | } 206 | 207 | export { withComputed }; 208 | -------------------------------------------------------------------------------- /docs/.vitepress/config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress'; 2 | 3 | // https://vitepress.dev/reference/site-config 4 | export default defineConfig({ 5 | title: 'Nextbone', 6 | description: 'Backbone reimagined', 7 | base: '/nextbone/', 8 | themeConfig: { 9 | // https://vitepress.dev/reference/default-theme-config 10 | nav: [{ text: 'Home', link: '/' }, { text: 'Documentation', link: '/introduction' }], 11 | 12 | sidebar: [ 13 | { 14 | text: 'Overview', 15 | items: [ 16 | { text: 'Introduction', link: '/introduction' }, 17 | { text: 'Get started', link: '/get-started' } 18 | ] 19 | }, 20 | { 21 | text: 'Core API', 22 | items: [ 23 | { text: 'Events', link: '/events' }, 24 | { text: 'Model', link: '/model' }, 25 | { text: 'Collection', link: '/collection' } 26 | ] 27 | } 28 | ], 29 | 30 | socialLinks: [{ icon: 'github', link: 'https://github.com/blikblum/nextbone' }] 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Events 6 | 7 | **Events** is a class that can be mixed in to any object, giving the object the ability to bind and trigger custom named events. Events do not have to be declared before they are bound, and may take passed arguments. For example: 8 | 9 | ```js 10 | import { Events } from 'nextbone'; 11 | 12 | const object = {}; 13 | 14 | Events.extend(object); 15 | 16 | object.on('alert', function(msg) { 17 | alert('Triggered ' + msg); 18 | }); 19 | 20 | object.trigger('alert', 'an event'); 21 | ``` 22 | 23 | For example, to make a handy event dispatcher that can coordinate events among different areas of your application: `const dispatcher = Events.extend({})` 24 | 25 | Alternatively is possible to subclass Events: 26 | 27 | ```js 28 | import { Events } from 'nextbone'; 29 | 30 | class ClassWithEvents extends Events {} 31 | 32 | const object = new ClassWithEvents(); 33 | 34 | object.on('alert', function(msg) { 35 | alert('Triggered ' + msg); 36 | }); 37 | 38 | object.trigger('alert', 'an event'); 39 | ``` 40 | 41 | ### on 42 | 43 | `object.on(event, callback, [context])` 44 | Bind a **callback** function to an object. The callback will be invoked whenever the **event** is fired. If you have a large number of different events on a page, the convention is to use colons to namespace them: "poll:start", or "change:selection". The event string may also be a space-delimited list of several events... 45 | 46 | ```js 47 | book.on("change:title change:author", ...); 48 | ``` 49 | 50 | Callbacks bound to the special "all" event will be triggered when any event occurs, and are passed the name of the event as the first argument. For example, to proxy all events from one object to another: 51 | 52 | ```js 53 | proxy.on('all', function(eventName) { 54 | object.trigger(eventName); 55 | }); 56 | ``` 57 | 58 | All Nextbone event methods also support an event map syntax, as an alternative to positional arguments: 59 | 60 | ```js 61 | book.on({ 62 | 'change:author': authorPane.update, 63 | 'change:title change:subtitle': titleView.update, 64 | destroy: bookView.remove 65 | }); 66 | ``` 67 | 68 | To supply a **context** value for this when the callback is invoked, pass the optional last argument: model.on('change', this.render, this) or model.on({change: this.render}, this). 69 | 70 | ### off 71 | 72 | `object.off([event], [callback], [context])` 73 | Remove a previously-bound **callback** function from an object. If no **context** is specified, all of the versions of the callback with different contexts will be removed. If no callback is specified, all callbacks for the **event** will be removed. If no event is specified, callbacks for _all_ events will be removed. 74 | 75 | ```js 76 | // Removes just the `onChange` callback. 77 | object.off('change', onChange); 78 | 79 | // Removes all "change" callbacks. 80 | object.off('change'); 81 | 82 | // Removes the `onChange` callback for all events. 83 | object.off(null, onChange); 84 | 85 | // Removes all callbacks for `context` for all events. 86 | object.off(null, null, context); 87 | 88 | // Removes all callbacks on `object`. 89 | object.off(); 90 | ``` 91 | 92 | Note that calling model.off(), for example, will indeed remove _all_ events on the model — including events that Nextbone uses for internal bookkeeping. 93 | 94 | ### trigger 95 | 96 | `object.trigger(event, [*args])` 97 | Trigger callbacks for the given **event**, or space-delimited list of events. Subsequent arguments to **trigger** will be passed along to the event callbacks. 98 | 99 | ### once 100 | 101 | `object.once(event, callback, [context])` 102 | Just like [on](#Events-on), but causes the bound callback to fire only once before being removed. Handy for saying "the next time that X happens, do this". When multiple events are passed in using the space separated syntax, the event will fire once for every event you passed in, not once for a combination of all events 103 | 104 | ### listenTo 105 | 106 | `object.listenTo(other, event, callback)` 107 | Tell an **object** to listen to a particular event on an **other** object. The advantage of using this form, instead of other.on(event, callback, object), is that **listenTo** allows the **object** to keep track of the events, and they can be removed all at once later on. The **callback** will always be called with **object** as context. 108 | 109 | ```js 110 | view.listenTo(model, 'change', view.render); 111 | ``` 112 | 113 | ### stopListening 114 | 115 | `object.stopListening([other], [event], [callback])` 116 | Tell an **object** to stop listening to events. Either call **stopListening** with no arguments to have the **object** remove all of its [registered](#Events-listenTo) callbacks ... or be more precise by telling it to remove just the events it's listening to on a specific object, or a specific event, or just a specific callback. 117 | 118 | ```js 119 | view.stopListening(); 120 | 121 | view.stopListening(model); 122 | ``` 123 | 124 | ### listenToOnce 125 | 126 | `object.listenToOnce(other, event, callback)` 127 | Just like [listenTo](#Events-listenTo), but causes the bound callback to fire only once before being removed. 128 | 129 | ### Catalog of Events 130 | 131 | Here's the complete list of built-in Nextbone events, with arguments. You're also free to trigger your own events on Models, Collections and Views as you see fit. 132 | 133 | - **"add"** (model, collection, options) — when a model is added to a collection. 134 | - **"remove"** (model, collection, options) — when a model is removed from a collection. 135 | - **"update"** (collection, options) — single event triggered after any number of models have been added, removed or changed in a collection. 136 | - **"reset"** (collection, options) — when the collection's entire contents have been [reset](#Collection-reset). 137 | - **"sort"** (collection, options) — when the collection has been re-sorted. 138 | - **"change"** (model, options) — when a model's attributes have changed. 139 | - **"changeId"** (model, previousId, options) — when the model's id has been updated. 140 | - **"change:\[attribute\]"** (model, value, options) — when a specific attribute has been updated. 141 | - **"destroy"** (model, collection, options) — when a model is [destroyed](#Model-destroy). 142 | - **"request"** (model_or_collection, xhr, options) — when a model or collection has started a request to the server. 143 | - **"sync"** (model_or_collection, response, options) — when a model or collection has been successfully synced with the server. 144 | - **"error"** (model_or_collection, xhr, options) — when a model's or collection's request to the server has failed. 145 | - **"invalid"** (model, error, options) — when a model's [validation](#Model-validate) fails on the client. 146 | - **"route:\[name\]"** (params) — Fired by the router when a specific route is matched. 147 | - **"route"** (route, params) — Fired by the router when _any_ route has been matched. 148 | - **"route"** (router, route, params) — Fired by history when _any_ route has been matched. 149 | - **"all"** — this special event fires for _any_ triggered event, passing the event name as the first argument followed by all trigger arguments. 150 | 151 | Generally speaking, when calling a function that emits an event (model.set, collection.add, and so on...), if you'd like to prevent the event from being triggered, you may pass {silent: true} as an option. Note that this is _rarely_, perhaps even never, a good idea. Passing through a specific flag in the options for your event callback to look at, and choose to ignore, will usually work out better. 152 | -------------------------------------------------------------------------------- /docs/get-started.md: -------------------------------------------------------------------------------- 1 | # Get started 2 | 3 | ## Install 4 | 5 | Use your favorite node package manager to install `nextbone` and its only dependency, `lodash-es` 6 | 7 | ::: code-group 8 | 9 | ```bash [npm] 10 | npm install nextbone lodash-es 11 | ``` 12 | 13 | ```bash [yarn] 14 | yarn add nextbone lodash-es 15 | ``` 16 | 17 | ::: 18 | 19 | ## Usage 20 | 21 | Declare a model and collection 22 | 23 | ```Javascript 24 | import { Model, Collection } from 'nextbone' 25 | 26 | class Task extends Model { 27 | static defaults = { 28 | title: '', 29 | done: false 30 | } 31 | } 32 | 33 | class Tasks extends Collection { 34 | static model = Task 35 | } 36 | 37 | const tasks = new Tasks() 38 | tasks.fetch() 39 | ``` 40 | 41 | Define a web component using LitElement / [lit](https://lit.dev) 42 | 43 | ```Javascript 44 | import { LitElement, html } from 'lit' 45 | import { state, eventHandler } from 'nextbone' 46 | 47 | class TasksView extends LitElement { 48 | @state 49 | tasks = new Tasks() 50 | 51 | @eventHandler('click', '#fetch') 52 | fetchTasks() { 53 | this.tasks.fetch() 54 | } 55 | 56 | render() { 57 | return html` 58 |

Tasks

59 |
    60 | ${tasks.map(task => { 61 | html`
  • ${task.get('title')}
  • ` 62 | })} 63 |
64 | 65 | ` 66 | } 67 | } 68 | 69 | customElements.define('tasks-view', TasksView) 70 | 71 | document.body.innerHTML = '' 72 | ``` 73 | 74 | ## More 75 | 76 | Check out the documentation for [Events](events.md), [Model](model.md) and [Collection](collection.md). 77 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | # https://vitepress.dev/reference/default-theme-home-page 3 | layout: home 4 | 5 | hero: 6 | name: 'Nextbone' 7 | text: 'Backbone reimagined' 8 | tagline: Old school models with ES classes and modules 9 | actions: 10 | - theme: brand 11 | text: Documentation 12 | link: /introduction 13 | 14 | features: 15 | - title: Feature A 16 | details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 17 | - title: Feature B 18 | details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 19 | - title: Feature C 20 | details: Lorem ipsum dolor sit amet, consectetur adipiscing elit 21 | --- 22 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Introduction 6 | 7 | Nextbone is a conversion of venerable [Backbone](http://backbonejs.org/) using modern Javascript features. It also replaces the View layer by a set of utilities to integrate with Web Components. 8 | 9 | ## Features 10 | 11 | - Keeps Backbone features / behavior with minimal changes. _In fact, most of the code is untouched_ 12 | - Uses EcmaScript Modules and Classes 13 | - Fully tree shackable 14 | - Seamless integration with Web Components (specially [LitElement](https://lit-element.polymer-project.org/)) 15 | -------------------------------------------------------------------------------- /dom-utils.js: -------------------------------------------------------------------------------- 1 | export class Region { 2 | constructor(targetEl, currentEl = targetEl.firstElementChild) { 3 | this.targetEl = targetEl; 4 | this.isSwappingEl = false; 5 | this.currentEl = currentEl; 6 | } 7 | 8 | show(el) { 9 | this.isSwappingEl = this.currentEl != null; 10 | 11 | if (this.currentEl) { 12 | this.empty(); 13 | } 14 | 15 | this.attachEl(el); 16 | 17 | this.currentEl = el; 18 | this.isSwappingEl = false; 19 | } 20 | 21 | empty() { 22 | if (this.currentEl) { 23 | this.detachEl(this.currentEl); 24 | } 25 | this.currentEl = undefined; 26 | } 27 | 28 | attachEl(el) { 29 | this.targetEl.appendChild(el); 30 | } 31 | 32 | detachEl(el) { 33 | this.targetEl.removeChild(el); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } -------------------------------------------------------------------------------- /localstorage.js: -------------------------------------------------------------------------------- 1 | import { sync, Collection } from './nextbone.js'; 2 | 3 | /** Generates 4 random hex digits 4 | * @returns {string} 4 Random hex digits 5 | */ 6 | function s4() { 7 | const rand = (1 + Math.random()) * 0x10000; 8 | return (rand | 0).toString(16).substring(1); 9 | } 10 | 11 | /** Generate a pseudo-guid 12 | * @returns {string} A GUID-like string. 13 | */ 14 | export function guid() { 15 | return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; 16 | } 17 | 18 | /** The default serializer for transforming your saved data to localStorage */ 19 | const defaultSerializer = { 20 | /** Return a JSON-serialized string representation of item 21 | * @param {Object} item - The encoded model data 22 | * @returns {string} A JSON-encoded string 23 | */ 24 | serialize(item) { 25 | return typeof item === 'object' && item ? JSON.stringify(item) : item; 26 | }, 27 | 28 | /** Custom deserialization for data. 29 | * @param {string} data - JSON-encoded string 30 | * @returns {Object} The object result of parsing data 31 | */ 32 | deserialize(data) { 33 | return JSON.parse(data); 34 | } 35 | }; 36 | 37 | function initializeData(instance, name, data) { 38 | const records = []; 39 | if (typeof data === 'function') data = data(); 40 | if (!Array.isArray(data)) data = [data]; 41 | const idAttribute = 42 | instance instanceof Collection 43 | ? (instance.model || instance.constructor.model || {}).idAttribute || 'id' 44 | : instance.idAttribute; 45 | data.forEach(item => { 46 | let id = item[idAttribute]; 47 | if (!id && id !== 0) { 48 | item[idAttribute] = id = guid(); 49 | } 50 | window.localStorage.setItem(`${name}-${id}`, JSON.stringify(item)); 51 | records.push(id); 52 | }); 53 | window.localStorage.setItem(name, records.join(',')); 54 | } 55 | 56 | export function bindLocalStorage(instance, name, { serializer, initialData } = {}) { 57 | instance.localStorage = new LocalStorage(name, serializer); 58 | let revision = revisionMap[name] || 0; 59 | if (initialData && !(revision || window.localStorage.getItem(name))) { 60 | initializeData(instance, name, initialData); 61 | revisionMap[name] = ++revision; 62 | } 63 | } 64 | 65 | const revisionMap = {}; 66 | 67 | /** LocalStorage proxy class for Backbone models. 68 | * Usage: 69 | * export const MyModel = Backbone.Model.extend({ 70 | * localStorage: new LocalStorage('MyModelName') 71 | * }); 72 | */ 73 | class LocalStorage { 74 | constructor(name = '', serializer = defaultSerializer) { 75 | this.name = name; 76 | this.serializer = serializer; 77 | } 78 | 79 | /** Return the global localStorage variable 80 | * @returns {Object} Local Storage reference. 81 | */ 82 | localStorage() { 83 | return window.localStorage; 84 | } 85 | 86 | /** Returns the records associated with store 87 | * @returns {Array} The records. 88 | */ 89 | getRecords() { 90 | if (!this.records || revisionMap[this.name] !== this.revision) { 91 | const store = this._getItem(this.name); 92 | this.revision = revisionMap[this.name]; 93 | return (store && store.split(',')) || []; 94 | } 95 | return this.records; 96 | } 97 | 98 | /** Save the current status to localStorage 99 | * @returns {undefined} 100 | */ 101 | save(records) { 102 | this._setItem(this.name, records.join(',')); 103 | this.records = records; 104 | let revision = revisionMap[this.name] || 0; 105 | this.revision = revisionMap[this.name] = ++revision; 106 | } 107 | 108 | /** Add a new model with a unique GUID, if it doesn't already have its own ID 109 | * @param {Model} model - The Backbone Model to save to LocalStorage 110 | * @returns {Model} The saved model 111 | */ 112 | create(model) { 113 | if (!model.id && model.id !== 0) { 114 | model.id = guid(); 115 | model.set(model.idAttribute, model.id); 116 | } 117 | 118 | this._setItem(this._itemName(model.id), this.serializer.serialize(model)); 119 | const records = this.getRecords(); 120 | records.push(model.id.toString()); 121 | this.save(records); 122 | 123 | return this.find(model); 124 | } 125 | 126 | /** Update an existing model in LocalStorage 127 | * @param {Model} model - The model to update 128 | * @returns {Model} The updated model 129 | */ 130 | update(model) { 131 | this._setItem(this._itemName(model.id), this.serializer.serialize(model)); 132 | 133 | const modelId = model.id.toString(); 134 | const records = this.getRecords(); 135 | 136 | if (!records.includes(modelId)) { 137 | records.push(modelId); 138 | this.save(records); 139 | } 140 | return this.find(model); 141 | } 142 | 143 | /** Retrieve a model from local storage by model id 144 | * @param {Model} model - The Backbone Model to lookup 145 | * @returns {Model} The model from LocalStorage 146 | */ 147 | find(model) { 148 | return this.serializer.deserialize(this._getItem(this._itemName(model.id))); 149 | } 150 | 151 | /** Return all models from LocalStorage 152 | * @returns {Array} The array of models stored 153 | */ 154 | findAll() { 155 | const records = this.getRecords(); 156 | return records 157 | .map(id => this.serializer.deserialize(this._getItem(this._itemName(id)))) 158 | .filter(item => item != null); 159 | } 160 | 161 | /** Delete a model from `this.data`, returning it. 162 | * @param {Model} model - Model to delete 163 | * @returns {Model} Model removed from this.data 164 | */ 165 | destroy(model) { 166 | this._removeItem(this._itemName(model.id)); 167 | const newRecords = this.getRecords().filter(id => id != model.id); // eslint-disable-line eqeqeq 168 | 169 | this.save(newRecords); 170 | 171 | return model; 172 | } 173 | 174 | /** Number of items in localStorage 175 | * @returns {integer} - Number of items 176 | */ 177 | _storageSize() { 178 | return window.localStorage.length; 179 | } 180 | 181 | /** Return the item from localStorage 182 | * @param {string} name - Name to lookup 183 | * @returns {string} Value from localStorage 184 | */ 185 | _getItem(name) { 186 | return window.localStorage.getItem(name); 187 | } 188 | 189 | /** Return the item name to lookup in localStorage 190 | * @param {integer} id - Item ID 191 | * @returns {string} Item name 192 | */ 193 | _itemName(id) { 194 | return `${this.name}-${id}`; 195 | } 196 | 197 | /** Proxy to the localStorage setItem value method 198 | * @param {string} key - LocalStorage key to set 199 | * @param {string} value - LocalStorage value to set 200 | * @returns {undefined} 201 | */ 202 | _setItem(key, value) { 203 | window.localStorage.setItem(key, value); 204 | } 205 | 206 | /** Proxy to the localStorage removeItem method 207 | * @param {string} key - LocalStorage key to remove 208 | * @returns {undefined} 209 | */ 210 | _removeItem(key) { 211 | window.localStorage.removeItem(key); 212 | } 213 | } 214 | 215 | /** Returns the localStorage attribute for a model 216 | * @param {Model} model - Model to get localStorage 217 | * @returns {Storage} The localstorage 218 | */ 219 | function getLocalStorage(model) { 220 | return model.localStorage || (model.collection && model.collection.localStorage); 221 | } 222 | 223 | /** Override Backbone's `sync` method to run against localStorage 224 | * @param {string} method - One of read/create/update/delete 225 | * @param {Model} model - Backbone model to sync 226 | * @param {Object} options - Options object, use `ajaxSync: true` to run the 227 | * operation against the server in which case, options will also be passed into 228 | * `jQuery.ajax` 229 | * @returns {undefined} 230 | */ 231 | function localStorageSync(method, model, options) { 232 | const store = getLocalStorage(model); 233 | let resp, errorMessage; 234 | let resolve, reject; 235 | const promise = new Promise((res, rej) => { 236 | resolve = res; 237 | reject = rej; 238 | }); 239 | 240 | try { 241 | switch (method) { 242 | case 'read': 243 | resp = typeof model.id === 'undefined' ? store.findAll() : store.find(model); 244 | break; 245 | case 'create': 246 | resp = store.create(model); 247 | break; 248 | case 'patch': 249 | case 'update': 250 | resp = store.update(model); 251 | break; 252 | case 'delete': 253 | resp = store.destroy(model); 254 | break; 255 | } 256 | } catch (error) { 257 | if (error.code === 22 && store._storageSize() === 0) { 258 | errorMessage = 'Private browsing is unsupported'; 259 | } else { 260 | errorMessage = error.message; 261 | } 262 | } 263 | 264 | if (resp && !errorMessage) { 265 | resolve(resp); 266 | } else { 267 | reject(new Error(errorMessage || 'Record Not Found')); 268 | } 269 | 270 | return promise; 271 | } 272 | 273 | const previousSync = sync.handler; 274 | 275 | /** Get the local or ajax sync call 276 | * @param {Model} model - Model to sync 277 | * @param {object} options - Options to pass, takes ajaxSync 278 | * @returns {function} The sync method that will be called 279 | */ 280 | function getSyncMethod(model, options) { 281 | const forceAjaxSync = options.ajaxSync; 282 | const hasLocalStorage = getLocalStorage(model); 283 | 284 | return !forceAjaxSync && hasLocalStorage ? localStorageSync : previousSync; 285 | } 286 | 287 | sync.handler = function localStorageSyncHandler(method, model, options = {}) { 288 | const fn = getSyncMethod(model, options); 289 | return fn.call(this, method, model, options); 290 | }; 291 | 292 | const createClass = (ModelClass, name, options) => { 293 | return class extends ModelClass { 294 | constructor(...args) { 295 | super(...args); 296 | bindLocalStorage(this, name, options); 297 | } 298 | }; 299 | }; 300 | 301 | export const localStorage = (name, options) => ctorOrDescriptor => { 302 | if (typeof ctorOrDescriptor === 'function') { 303 | return createClass(ctorOrDescriptor, name, options); 304 | } 305 | const { kind, elements } = ctorOrDescriptor; 306 | return { 307 | kind, 308 | elements, 309 | finisher(ctor) { 310 | return createClass(ctor, name, options); 311 | } 312 | }; 313 | }; 314 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextbone", 3 | "version": "0.28.4", 4 | "description": "BackboneJS with ES classes and web components", 5 | "url": "https://github.com/blikblum/nextbone", 6 | "type": "module", 7 | "keywords": [ 8 | "model", 9 | "view", 10 | "controller", 11 | "router", 12 | "server", 13 | "client", 14 | "browser" 15 | ], 16 | "author": "Luiz Américo Pereira Câmara", 17 | "main": "nextbone.js", 18 | "types": "types/nextbone.d.ts", 19 | "exports": { 20 | ".": { 21 | "types": "./types/nextbone.d.ts", 22 | "default": "./nextbone.js" 23 | }, 24 | "./class-utils": { 25 | "types": "./types/class-utils.d.ts", 26 | "default": "./class-utils.js" 27 | }, 28 | "./class-utils.js": { 29 | "types": "./types/class-utils.d.ts", 30 | "default": "./class-utils.js" 31 | }, 32 | "./computed": { 33 | "types": "./types/computed.d.ts", 34 | "default": "./computed.js" 35 | }, 36 | "./computed.js": { 37 | "types": "./types/computed.d.ts", 38 | "default": "./computed.js" 39 | }, 40 | "./dom-utils": { 41 | "types": "./types/dom-utils.d.ts", 42 | "default": "./dom-utils.js" 43 | }, 44 | "./dom-utils.js": { 45 | "types": "./types/dom-utils.d.ts", 46 | "default": "./dom-utils.js" 47 | }, 48 | "./form": { 49 | "types": "./types/form.d.ts", 50 | "default": "./form.js" 51 | }, 52 | "./form.js": { 53 | "types": "./types/form.d.ts", 54 | "default": "./form.js" 55 | }, 56 | "./localstorage": { 57 | "types": "./types/localstorage.d.ts", 58 | "default": "./localstorage.js" 59 | }, 60 | "./localstorage.js": { 61 | "types": "./types/localstorage.d.ts", 62 | "default": "./localstorage.js" 63 | }, 64 | "./validation": { 65 | "types": "./types/validation.d.ts", 66 | "default": "./validation.js" 67 | }, 68 | "./validation.js": { 69 | "types": "./types/validation.d.ts", 70 | "default": "./validation.js" 71 | }, 72 | "./virtualcollection": { 73 | "types": "./types/virtualcollection.d.ts", 74 | "default": "./virtualcollection.js" 75 | }, 76 | "./virtualcollection.js": { 77 | "types": "./types/virtualcollection.d.ts", 78 | "default": "./virtualcollection.js" 79 | } 80 | }, 81 | "dependencies": { 82 | "lodash-es": "^4.17.21" 83 | }, 84 | "devDependencies": { 85 | "@babel/core": "^7.14.6", 86 | "@babel/eslint-parser": "^7.16.3", 87 | "@babel/plugin-proposal-class-properties": "^7.14.5", 88 | "@babel/plugin-proposal-decorators": "^7.14.5", 89 | "@babel/plugin-proposal-private-methods": "^7.18.6", 90 | "@babel/preset-env": "^7.14.7", 91 | "@babel/register": "^7.14.5", 92 | "@open-wc/testing-helpers": "^3.0.1", 93 | "@rollup/plugin-babel": "^5.3.0", 94 | "@web/test-runner": "^0.20.2", 95 | "@web/test-runner-puppeteer": "^0.18.0", 96 | "chai": "^5.2.0", 97 | "chai-as-promised": "^8.0.1", 98 | "cross-env": "^5.2.0", 99 | "eslint": "^8.2.0", 100 | "eslint-config-prettier": "^8.3.0", 101 | "eslint-plugin-chai-friendly": "^1.0.1", 102 | "jquery": "^3.3.1", 103 | "lit-element": "^2.0.1", 104 | "lit-html": "^1.0.0", 105 | "mocha": "^8.0.0", 106 | "prettier": "^1.16.4", 107 | "sinon": "^11.1.1", 108 | "sinon-chai": "^3.7.0", 109 | "turndown": "^7.1.2", 110 | "typescript": "^5.5.4", 111 | "vitepress": "^1.0.0-alpha.75" 112 | }, 113 | "scripts": { 114 | "test": "web-test-runner test/core/*.js --node-resolve --puppeteer", 115 | "test:computed": "web-test-runner test/computed/*.js --node-resolve --puppeteer", 116 | "test:fetch": "mocha --require @babel/register test/fetch/setup.js test/fetch/*.js", 117 | "test:localstorage": "web-test-runner test/localstorage/*.js --node-resolve --puppeteer", 118 | "test:validation": "mocha --require @babel/register --require test/validation/setup-globals.js --ui exports --recursive test/validation/", 119 | "test:virtualcollection": "web-test-runner test/virtualcollection/*.js --node-resolve --puppeteer", 120 | "test:form": "web-test-runner test/form/*.js --node-resolve --puppeteer", 121 | "test:class-utils": "mocha --require @babel/register test/class-utils", 122 | "test:all": "npm run test && npm run test:computed && npm run test:fetch && npm run test:localstorage && npm run test:validation && npm run test:virtualcollection && npm run test:form && npm run test:class-utils", 123 | "lint": "eslint nextbone.js computed.js localstorage.js virtualcollection.js form.js test/core/*.js", 124 | "format": "prettier nextbone.js computed.js localstorage.js virtualcollection.js form.js test/**/*.js --write", 125 | "docs:dev": "vitepress dev docs", 126 | "docs:build": "vitepress build docs", 127 | "docs:preview": "vitepress preview docs", 128 | "types": "tsc --project tsconfig.types.json" 129 | }, 130 | "sideEffects": false, 131 | "license": "MIT", 132 | "repository": { 133 | "type": "git", 134 | "url": "https://github.com/blikblum/nextbone.git" 135 | }, 136 | "files": [ 137 | "types", 138 | "nextbone.js", 139 | "computed.js", 140 | "localstorage.js", 141 | "validation.js", 142 | "virtualcollection.js", 143 | "form.js", 144 | "class-utils.js", 145 | "dom-utils.js", 146 | "utils.js", 147 | "LICENSE" 148 | ], 149 | "packageManager": "yarn@3.3.1" 150 | } 151 | -------------------------------------------------------------------------------- /test/class-utils/asyncmethod.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import sinonChai from 'sinon-chai'; 3 | import { use, expect } from 'chai'; 4 | import { defineAsyncMethods, asyncMethod } from '../../class-utils.js'; 5 | 6 | use(sinonChai); 7 | 8 | describe('asyncMethod', function() { 9 | let fooStub; 10 | let barSpy; 11 | let startSpy; 12 | let errorSpy; 13 | let myService; 14 | 15 | beforeEach(function() { 16 | fooStub = sinon.stub(); 17 | barSpy = sinon.spy(); 18 | startSpy = sinon.spy(); 19 | errorSpy = sinon.spy(); 20 | class MyService { 21 | start() { 22 | startSpy.call(this); 23 | } 24 | 25 | @asyncMethod 26 | foo(...args) { 27 | fooStub.apply(this, args); 28 | } 29 | 30 | @asyncMethod 31 | bar(...args) { 32 | barSpy.apply(this, args); 33 | return 'x'; 34 | } 35 | 36 | onError(e) { 37 | errorSpy.call(this, e); 38 | } 39 | } 40 | 41 | myService = new MyService(); 42 | }); 43 | 44 | it('should return a promise', function() { 45 | expect(myService.foo()).to.be.instanceOf(Promise); 46 | }); 47 | 48 | it('should resolve the promise with the value returned by the method', function() { 49 | return myService.bar().then(result => { 50 | expect(result).to.equal('x'); 51 | }); 52 | }); 53 | 54 | it('should call original method', function() { 55 | return myService.foo(1, 'a').then(() => { 56 | expect(fooStub).to.have.been.calledOnce; 57 | expect(fooStub).to.have.been.calledWith(1, 'a'); 58 | expect(fooStub).to.have.been.calledOn(myService); 59 | }); 60 | }); 61 | 62 | it('should call start() before calling the function', function() { 63 | return myService.foo().then(() => { 64 | expect(startSpy).to.have.been.calledBefore(fooStub); 65 | }); 66 | }); 67 | 68 | it('should only call start() once', function() { 69 | return Promise.all([myService.foo(), myService.bar()]).then(() => { 70 | expect(startSpy).to.have.been.calledOnce; 71 | }); 72 | }); 73 | 74 | it('should call onError when an async method errors', function() { 75 | const err = new Error('Err!'); 76 | fooStub['throws'](err); 77 | 78 | return myService.foo().then( 79 | () => { 80 | expect(fooStub).to.have.thrown(err); 81 | }, 82 | error => { 83 | expect(error).to.equal(error); 84 | expect(errorSpy).to.have.been.calledWith(error); 85 | expect(errorSpy).to.have.been.calledOn(myService); 86 | } 87 | ); 88 | }); 89 | }); 90 | 91 | describe('defineAsyncMethods', function() { 92 | let fooStub; 93 | let barSpy; 94 | let startSpy; 95 | let errorSpy; 96 | let myService; 97 | 98 | beforeEach(function() { 99 | fooStub = sinon.stub(); 100 | barSpy = sinon.spy(); 101 | startSpy = sinon.spy(); 102 | errorSpy = sinon.spy(); 103 | class MyService { 104 | start() { 105 | startSpy.call(this); 106 | } 107 | 108 | foo(...args) { 109 | fooStub.apply(this, args); 110 | } 111 | bar(...args) { 112 | barSpy.apply(this, args); 113 | return 'x'; 114 | } 115 | 116 | onError(e) { 117 | errorSpy.call(this, e); 118 | } 119 | } 120 | 121 | defineAsyncMethods(MyService, ['foo', 'bar']); 122 | 123 | myService = new MyService(); 124 | }); 125 | 126 | it('should return a promise', function() { 127 | expect(myService.foo()).to.be.instanceOf(Promise); 128 | }); 129 | 130 | it('should resolve the promise with the value returned by the method', function() { 131 | return myService.bar().then(result => { 132 | expect(result).to.equal('x'); 133 | }); 134 | }); 135 | 136 | it('should call original method', function() { 137 | return myService.foo(1, 'a').then(() => { 138 | expect(fooStub).to.have.been.calledOnce; 139 | expect(fooStub).to.have.been.calledWith(1, 'a'); 140 | expect(fooStub).to.have.been.calledOn(myService); 141 | }); 142 | }); 143 | 144 | it('should call start() before calling the function', function() { 145 | return myService.foo().then(() => { 146 | expect(startSpy).to.have.been.calledBefore(fooStub); 147 | }); 148 | }); 149 | 150 | it('should only call start() once', function() { 151 | return Promise.all([myService.foo(), myService.bar()]).then(() => { 152 | expect(startSpy).to.have.been.calledOnce; 153 | }); 154 | }); 155 | 156 | it('should call onError when an async method errors', function() { 157 | const err = new Error('Err!'); 158 | fooStub['throws'](err); 159 | 160 | return myService.foo().then( 161 | () => { 162 | expect(fooStub).to.have.thrown(err); 163 | }, 164 | error => { 165 | expect(error).to.equal(error); 166 | expect(errorSpy).to.have.been.calledWith(error); 167 | expect(errorSpy).to.have.been.calledOn(myService); 168 | } 169 | ); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/core/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "mocha": true 5 | }, 6 | "plugins": [ 7 | "chai-friendly" 8 | ], 9 | "rules": { 10 | "no-unused-expressions": 0, 11 | "chai-friendly/no-unused-expressions": 2 12 | } 13 | } -------------------------------------------------------------------------------- /test/core/delegate.js: -------------------------------------------------------------------------------- 1 | import { fixtureSync } from '@open-wc/testing-helpers'; 2 | 3 | import * as Backbone from 'nextbone'; 4 | import * as _ from 'lodash-es'; 5 | 6 | import { expect } from 'chai'; 7 | 8 | const elHTML = ` 9 |
10 |

Test

11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | `; 19 | 20 | class TestShadowDOM extends HTMLElement { 21 | constructor() { 22 | super(); 23 | this.renderRoot = this.attachShadow({ mode: 'open' }); 24 | } 25 | 26 | connectedCallback() { 27 | this.renderRoot.innerHTML = elHTML; 28 | } 29 | } 30 | 31 | const elShadowTag = 'delegate-shadow-el'; 32 | customElements.define(elShadowTag, TestShadowDOM); 33 | 34 | describe('delegate', () => { 35 | afterEach(() => { 36 | Backbone.delegate.$ = null; 37 | }); 38 | 39 | const triggerEvent = (el, oneChildEl, twoEl) => { 40 | oneChildEl.click(); 41 | oneChildEl.dispatchEvent(new CustomEvent('my-delegated-event', { bubbles: true })); 42 | twoEl.click(); 43 | twoEl.dispatchEvent(new CustomEvent('my-delegated-event', { bubbles: true })); 44 | el.dispatchEvent(new CustomEvent('my-event')); 45 | }; 46 | 47 | it('should listen to events triggered in element and its children', async () => { 48 | const el = fixtureSync(elHTML); 49 | const oneEl = el.querySelector('.one'); 50 | const oneChildEl = el.querySelector('.one-child'); 51 | const twoEl = el.querySelector('.two'); 52 | 53 | function oneClick(e) { 54 | expect(this).to.equal(el, 'this should be the element instance'); 55 | expect(e.target).to.equal(oneChildEl, 'target should be .one-child element'); 56 | expect(e.selectorTarget).to.equal(oneEl, 'selectorTarget should be .one element'); 57 | } 58 | function twoClick(e) { 59 | expect(this).to.equal(el, 'this should be the element instance'); 60 | expect(e.target).to.equal(twoEl, 'target should be .two element'); 61 | expect(e.selectorTarget).to.equal(twoEl, 'selectorTarget should be .two element'); 62 | } 63 | function selfClick(e) { 64 | expect(this).to.equal(el, 'this should be the element instance'); 65 | expect(e.target).to.equal(el, 'target should be be the element instance'); 66 | expect(e.selectorTarget, 'selectorTarget should be undefined').to.be.undefined; 67 | } 68 | 69 | const handler1 = Backbone.delegate(el, 'click', '.one', oneClick); 70 | const handler2 = Backbone.delegate(el, 'my-delegated-event', '.one', oneClick); 71 | const handler3 = Backbone.delegate(el, 'click', '.two', twoClick); 72 | const handler4 = Backbone.delegate(el, 'my-delegated-event', '.two', twoClick); 73 | const handler5 = Backbone.delegate(el, 'my-event', undefined, selfClick); 74 | 75 | triggerEvent(el, oneChildEl, twoEl); 76 | Backbone.undelegate(el, handler1); 77 | Backbone.undelegate(el, handler2); 78 | Backbone.undelegate(el, handler3); 79 | Backbone.undelegate(el, handler4); 80 | Backbone.undelegate(el, handler5); 81 | triggerEvent(el, oneChildEl, twoEl); 82 | }); 83 | 84 | it('should allow to configure the context option', () => { 85 | const el = fixtureSync(elHTML); 86 | 87 | const oneChildEl = el.querySelector('.one-child'); 88 | const twoEl = el.querySelector('.two'); 89 | const customContext1 = {}; 90 | const customContext2 = { foo: 'bar' }; 91 | function oneClick(e) { 92 | expect(this).to.equal(customContext1, 'this should be the the passed context'); 93 | } 94 | function twoClick(e) { 95 | expect(this).to.equal(customContext2, 'this should be the passed context'); 96 | } 97 | function selfClick(e) { 98 | expect(this).to.equal(customContext1, 'this should be the the passed context'); 99 | } 100 | 101 | Backbone.delegate(el, 'click', '.one', oneClick, customContext1); 102 | Backbone.delegate(el, 'my-delegated-event', '.one', oneClick, customContext1); 103 | Backbone.delegate(el, 'click', '.two', twoClick, customContext2); 104 | Backbone.delegate(el, 'my-delegated-event', '.two', twoClick, customContext2); 105 | Backbone.delegate(el, 'my-event', undefined, selfClick, customContext1); 106 | 107 | triggerEvent(el, oneChildEl, twoEl); 108 | }); 109 | 110 | it('should support non bubbable events', async () => { 111 | let el, oneEl, twoEl; 112 | function oneBlur(e) { 113 | expect(this).to.equal(el, 'this should be the element instance'); 114 | expect(e.target).to.equal(oneEl, 'target should be .one element'); 115 | expect(e.selectorTarget).to.equal(oneEl, 'selectorTarget should be .one element'); 116 | } 117 | function twoFocus(e) { 118 | expect(this).to.equal(el, 'this should be the element instance'); 119 | expect(e.target).to.equal(twoEl, 'target should be .two element'); 120 | expect(e.selectorTarget).to.equal(twoEl, 'selectorTarget should be .two element'); 121 | } 122 | el = fixtureSync('
'); 123 | oneEl = el.querySelector('.one'); 124 | twoEl = el.querySelector('.two'); 125 | oneEl.focus(); 126 | Backbone.delegate(el, 'blur', '.one', oneBlur); 127 | Backbone.delegate(el, 'focus', '.two', twoFocus); 128 | twoEl.focus(); 129 | }); 130 | 131 | it('should support events triggered in a element with shadowDOM', async () => { 132 | let el, oneEl, oneChildEl, twoEl; 133 | 134 | function oneClick(e) { 135 | expect(this).to.equal(el.shadowRoot, 'this should be the element shadowRoot'); 136 | expect(e.target).to.equal(oneChildEl, 'target should be .one-child element'); 137 | expect(e.selectorTarget).to.equal(oneEl, 'selectorTarget should be .one element'); 138 | } 139 | 140 | function twoClick(e) { 141 | expect(this).to.equal(el.shadowRoot, 'this should be the element shadowRoot'); 142 | expect(e.target).to.equal(twoEl, 'target should be .two element'); 143 | expect(e.selectorTarget).to.equal(twoEl, 'selectorTarget should be .two element'); 144 | } 145 | 146 | function selfClick(e) { 147 | expect(this).to.equal(el, 'this should be the element instance'); 148 | expect(e.target).to.equal(el, 'target should be be the element instance'); 149 | expect(e.selectorTarget, 'selectorTarget should be undefined').to.be.undefined; 150 | } 151 | 152 | el = fixtureSync(`<${elShadowTag}>`); 153 | const handler1 = Backbone.delegate(el.shadowRoot, 'click', '.one', oneClick); 154 | const handler2 = Backbone.delegate(el.shadowRoot, 'my-delegated-event', '.one', oneClick); 155 | const handler3 = Backbone.delegate(el.shadowRoot, 'click', '.two', twoClick); 156 | const handler4 = Backbone.delegate(el.shadowRoot, 'my-delegated-event', '.two', twoClick); 157 | const handler5 = Backbone.delegate(el, 'my-event', undefined, selfClick); 158 | oneEl = el.renderRoot.querySelector('.one'); 159 | oneChildEl = el.renderRoot.querySelector('.one-child'); 160 | twoEl = el.renderRoot.querySelector('.two'); 161 | triggerEvent(el, oneChildEl, twoEl); 162 | Backbone.undelegate(el.shadowRoot, handler1); 163 | Backbone.undelegate(el.shadowRoot, handler2); 164 | Backbone.undelegate(el, handler3); 165 | Backbone.undelegate(el, handler4); 166 | Backbone.undelegate(el, handler5); 167 | triggerEvent(el, oneChildEl, twoEl); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/core/observable.js: -------------------------------------------------------------------------------- 1 | import * as Backbone from 'nextbone'; 2 | import * as _ from 'lodash-es'; 3 | import chaiAsPromised from 'chai-as-promised'; 4 | 5 | import { use, expect } from 'chai'; 6 | 7 | use(chaiAsPromised); 8 | 9 | describe('Nexbone.observable', function() { 10 | it('trigger "change" events', function() { 11 | class WithObservable extends Backbone.Events { 12 | @Backbone.observable 13 | prop; 14 | } 15 | var obj = new WithObservable(); 16 | obj.on('change', function() { 17 | expect(true).to.be.true; 18 | }); 19 | obj.on('change:prop', function() { 20 | expect(true).to.be.true; 21 | }); 22 | obj.prop = 3; 23 | 24 | expect(obj.prop).to.equal(3); 25 | }); 26 | 27 | it('pass instance and value to change events', function() { 28 | class WithObservable extends Backbone.Events { 29 | @Backbone.observable 30 | prop; 31 | } 32 | var obj = new WithObservable(); 33 | obj.prop = 3; 34 | 35 | obj.on('change', function(instance) { 36 | expect(instance).to.equal(obj); 37 | }); 38 | obj.on('change:prop', function(instance, value, oldValue) { 39 | expect(instance).to.equal(obj); 40 | expect(value).to.equal(5); 41 | expect(oldValue).to.equal(3); 42 | }); 43 | obj.prop = 5; 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /test/core/sync.js: -------------------------------------------------------------------------------- 1 | import * as Backbone from 'nextbone'; 2 | import * as _ from 'lodash-es'; 3 | 4 | import { expect } from 'chai'; 5 | 6 | describe('Backbone.sync', function() { 7 | let Library, library, attrs, ajaxSettings, ajaxResponse; 8 | let originalAjax; 9 | 10 | before(function() { 11 | Library = class extends Backbone.Collection { 12 | url() { 13 | return '/library'; 14 | } 15 | }; 16 | attrs = { 17 | title: 'The Tempest', 18 | author: 'Bill Shakespeare', 19 | length: 123 20 | }; 21 | }); 22 | 23 | beforeEach(function() { 24 | originalAjax = Backbone.ajax.handler; 25 | Backbone.ajax.handler = async function ajaxHandler(settings) { 26 | ajaxSettings = settings; 27 | var response = ajaxResponse; 28 | ajaxResponse = undefined; 29 | await Promise.resolve(); 30 | return response; 31 | }; 32 | 33 | library = new Library(); 34 | library.create(attrs, { wait: false }); 35 | }); 36 | 37 | afterEach(function() { 38 | Backbone.ajax.handler = originalAjax; 39 | }); 40 | 41 | it('read', function() { 42 | library.fetch(); 43 | expect(ajaxSettings.url).to.equal('/library'); 44 | expect(ajaxSettings.type).to.equal('GET'); 45 | expect(ajaxSettings.dataType).to.equal('json'); 46 | expect(_.isEmpty(ajaxSettings.data)).to.be.ok; 47 | }); 48 | 49 | it('passing data', function() { 50 | library.fetch({ data: { a: 'a', one: 1 } }); 51 | expect(ajaxSettings.url).to.equal('/library'); 52 | expect(ajaxSettings.data.a).to.equal('a'); 53 | expect(ajaxSettings.data.one).to.equal(1); 54 | }); 55 | 56 | it('create', function() { 57 | let data = JSON.parse(ajaxSettings.data); 58 | expect(ajaxSettings.url).to.equal('/library'); 59 | expect(ajaxSettings.type).to.equal('POST'); 60 | expect(ajaxSettings.dataType).to.equal('json'); 61 | expect(data.title).to.equal('The Tempest'); 62 | expect(data.author).to.equal('Bill Shakespeare'); 63 | expect(data.length).to.equal(123); 64 | }); 65 | 66 | it('update', function() { 67 | library.first().save({ id: '1-the-tempest', author: 'William Shakespeare' }); 68 | let data = JSON.parse(ajaxSettings.data); 69 | expect(ajaxSettings.url).to.equal('/library/1-the-tempest'); 70 | expect(ajaxSettings.type).to.equal('PUT'); 71 | expect(ajaxSettings.dataType).to.equal('json'); 72 | expect(data.id).to.equal('1-the-tempest'); 73 | expect(data.title).to.equal('The Tempest'); 74 | expect(data.author).to.equal('William Shakespeare'); 75 | expect(data.length).to.equal(123); 76 | }); 77 | 78 | it('read model', function() { 79 | library.first().save({ id: '2-the-tempest', author: 'Tim Shakespeare' }); 80 | library.first().fetch(); 81 | expect(ajaxSettings.url).to.equal('/library/2-the-tempest'); 82 | expect(ajaxSettings.type).to.equal('GET'); 83 | expect(_.isEmpty(ajaxSettings.data)).to.be.ok; 84 | }); 85 | 86 | it('destroy', function() { 87 | library.first().save({ id: '2-the-tempest', author: 'Tim Shakespeare' }); 88 | library.first().destroy({ wait: true }); 89 | expect(ajaxSettings.url).to.equal('/library/2-the-tempest'); 90 | expect(ajaxSettings.type).to.equal('DELETE'); 91 | expect(ajaxSettings.data).to.be.undefined; 92 | }); 93 | 94 | it('urlError', function(done) { 95 | var model = new Backbone.Model(); 96 | try { 97 | model.fetch(); 98 | } catch (e) { 99 | model.fetch({ url: '/one/two' }); 100 | expect(ajaxSettings.url).to.equal('/one/two'); 101 | done(); 102 | } 103 | }); 104 | 105 | it('#1052 - `options` is optional.', function() { 106 | var model = new Backbone.Model(); 107 | model.url = '/test'; 108 | Backbone.sync.handler('create', model); 109 | // No expectation needed, ensuring no error is thrown 110 | }); 111 | 112 | it('Backbone.ajax', function(done) { 113 | Backbone.ajax.handler = function(settings) { 114 | expect(settings.url).to.equal('/test'); 115 | done(); 116 | return Promise.resolve(); 117 | }; 118 | var model = new Backbone.Model(); 119 | model.url = '/test'; 120 | Backbone.sync.handler('create', model); 121 | }); 122 | 123 | it('Call provided error callback on error.', function(done) { 124 | var model = new Backbone.Model(); 125 | model.url = '/test'; 126 | Backbone.sync.handler('read', model, { 127 | error: function() { 128 | expect(true).to.be.ok; 129 | done(); 130 | } 131 | }); 132 | ajaxSettings.error(); 133 | }); 134 | 135 | it('isLoading with customized sync method.', function() { 136 | class SpecialSyncModel extends Backbone.Model { 137 | sync() { 138 | return Promise.resolve({ x: 'y' }); 139 | } 140 | } 141 | var model = new SpecialSyncModel(); 142 | model.url = '/test'; 143 | expect(model.isLoading).to.equal(false); 144 | model 145 | .fetch({ 146 | success() { 147 | expect(model.isLoading).to.equal(false); 148 | } 149 | }) 150 | .then(function() { 151 | expect(model.isLoading).to.equal(false); 152 | }); 153 | expect(model.isLoading).to.equal(true); 154 | }); 155 | 156 | it('#2928 - Pass along `textStatus` and `errorThrown`.', function(done) { 157 | var model = new Backbone.Model(); 158 | model.url = '/test'; 159 | model.on('error', function(m, error) { 160 | expect(error).to.be.an('error'); 161 | expect(error.responseData).to.deep.equal({ message: 'oh no!' }); 162 | expect(error.textStatus).to.equal('textStatus'); 163 | done(); 164 | }); 165 | var ajax = Backbone.ajax.handler; 166 | Backbone.ajax.handler = function() { 167 | var error = new Error('not found'); 168 | error.textStatus = 'textStatus'; 169 | error.responseData = { message: 'oh no!' }; 170 | return Promise.reject(error); 171 | }; 172 | model.fetch(); 173 | Backbone.ajax.handler = ajax; 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /test/fetch/fetch.js: -------------------------------------------------------------------------------- 1 | import { mock } from 'node:test'; 2 | import sinon from 'sinon'; 3 | import { expect } from 'chai'; 4 | import * as Backbone from '../../nextbone.js'; 5 | 6 | var ajax = Backbone.ajax.handler; 7 | 8 | describe('fetch', function() { 9 | const fetchSpy = sinon.spy(); 10 | let textResponse = ''; 11 | let responseStatus = 200; 12 | function text() { 13 | return textResponse; 14 | } 15 | 16 | beforeEach(function() { 17 | textResponse = ''; 18 | responseStatus = 200; 19 | mock.method(global, 'fetch', async (...args) => { 20 | fetchSpy(...args); 21 | return new Response(textResponse, { 22 | status: responseStatus, 23 | headers: { 24 | 'Content-Type': 'application/json' 25 | } 26 | }); 27 | }); 28 | }); 29 | 30 | afterEach(function() { 31 | fetchSpy.resetHistory(); 32 | mock.reset(); 33 | }); 34 | 35 | describe('creating a request', function() { 36 | it('should pass the method and url to fetch', function() { 37 | ajax({ 38 | url: 'http://test', 39 | type: 'GET' 40 | }); 41 | 42 | sinon.assert.calledWith(fetchSpy, 'http://test', sinon.match.has('method', 'GET')); 43 | sinon.assert.calledWith(fetchSpy, 'http://test', sinon.match.has('body', undefined)); 44 | }); 45 | 46 | it('should stringify GET data when present', function() { 47 | ajax({ 48 | url: 'test', 49 | type: 'GET', 50 | data: { a: 1, b: 2 } 51 | }); 52 | sinon.assert.calledWith(fetchSpy, 'test?a=1&b=2'); 53 | }); 54 | 55 | it('should append to the querystring when one already present', function() { 56 | ajax({ 57 | url: 'test?foo=bar', 58 | type: 'GET', 59 | data: { a: 1, b: 2 } 60 | }); 61 | sinon.assert.calledWith(fetchSpy, 'test?foo=bar&a=1&b=2'); 62 | }); 63 | 64 | it('should send POSTdata when POSTing', function() { 65 | ajax({ 66 | url: 'test', 67 | type: 'POST', 68 | data: JSON.stringify({ a: 1, b: 2 }) 69 | }); 70 | 71 | sinon.assert.calledWith(fetchSpy, 'test', sinon.match.has('method', 'POST')); 72 | sinon.assert.calledWith(fetchSpy, 'test', sinon.match.has('body', '{"a":1,"b":2}')); 73 | }); 74 | }); 75 | 76 | describe('headers', function() { 77 | it('should set headers if none passed in', function() { 78 | ajax({ url: 'test', type: 'GET' }); 79 | sinon.assert.calledWith( 80 | fetchSpy, 81 | 'test', 82 | sinon.match({ 83 | headers: { 84 | Accept: 'application/json', 85 | 'Content-Type': 'application/json' 86 | } 87 | }) 88 | ); 89 | }); 90 | 91 | it('should use headers if passed in', function() { 92 | ajax({ 93 | url: 'test', 94 | type: 'GET', 95 | headers: { 96 | 'X-MyApp-Header': 'present' 97 | } 98 | }); 99 | 100 | sinon.assert.calledWith( 101 | fetchSpy, 102 | 'test', 103 | sinon.match({ 104 | headers: { 105 | Accept: 'application/json', 106 | 'Content-Type': 'application/json', 107 | 'X-MyApp-Header': 'present' 108 | } 109 | }) 110 | ); 111 | }); 112 | 113 | it('allows Accept and Content-Type headers to be overwritten', function() { 114 | ajax({ 115 | url: 'test', 116 | type: 'GET', 117 | headers: { 118 | Accept: 'custom', 119 | 'Content-Type': 'custom', 120 | 'X-MyApp-Header': 'present' 121 | } 122 | }); 123 | 124 | sinon.assert.calledWith( 125 | fetchSpy, 126 | 'test', 127 | sinon.match({ 128 | headers: { 129 | Accept: 'custom', 130 | 'Content-Type': 'custom', 131 | 'X-MyApp-Header': 'present' 132 | } 133 | }) 134 | ); 135 | }); 136 | }); 137 | 138 | describe('finishing a request', function() { 139 | it('should invoke the success callback on complete', function() { 140 | textResponse = 'ok'; 141 | return ajax({ 142 | url: 'http://test', 143 | type: 'GET', 144 | success: function(response) { 145 | expect(response).to.equal('ok'); 146 | } 147 | }); 148 | }); 149 | 150 | it('should parse response as json if dataType option is provided', function() { 151 | textResponse = JSON.stringify({ status: 'ok' }); 152 | return ajax({ 153 | url: 'http://test', 154 | dataType: 'json', 155 | type: 'GET', 156 | success: function(response) { 157 | expect(response).to.deep.equal({ status: 'ok' }); 158 | } 159 | }).then(function(response) { 160 | expect(response).to.deep.equal({ status: 'ok' }); 161 | }); 162 | }); 163 | 164 | it('should invoke the error callback on error', function(done) { 165 | textResponse = 'Server error'; 166 | responseStatus = 400; 167 | var promise = ajax({ 168 | url: 'test', 169 | type: 'GET', 170 | success: function(response) { 171 | throw new Error('this request should fail'); 172 | }, 173 | error: function(error) { 174 | expect(error.response.status).to.equal(400); 175 | } 176 | }); 177 | 178 | promise 179 | .then(function() { 180 | throw new Error('this request should fail'); 181 | }) 182 | ['catch'](function(error) { 183 | expect(error.response.status).to.equal(400); 184 | done(); 185 | }) 186 | ['catch'](function(error) { 187 | done(error); 188 | }); 189 | }); 190 | 191 | it('should not fail without error callback', function(done) { 192 | textResponse = 'Server error'; 193 | responseStatus = 400; 194 | var promise = ajax({ 195 | url: 'test', 196 | type: 'GET', 197 | success: function(response) { 198 | throw new Error('this request should fail'); 199 | } 200 | }); 201 | 202 | promise 203 | .then(function() { 204 | throw new Error('this request should fail'); 205 | }) 206 | ['catch'](function(error) { 207 | expect(error.response.status).to.equal(400); 208 | done(); 209 | }) 210 | ['catch'](function(error) { 211 | done(error); 212 | }); 213 | }); 214 | 215 | it('should parse json as property of Error on failing request', function(done) { 216 | textResponse = JSON.stringify({ code: 'INVALID_HORSE' }); 217 | responseStatus = 400; 218 | var promise = ajax({ 219 | dataType: 'json', 220 | url: 'test', 221 | type: 'GET' 222 | }); 223 | 224 | promise 225 | .then(function() { 226 | throw new Error('this request should fail'); 227 | }) 228 | ['catch'](function(error) { 229 | expect(error.responseData).to.deep.equal({ code: 'INVALID_HORSE' }); 230 | done(); 231 | }) 232 | ['catch'](function(error) { 233 | done(error); 234 | }); 235 | }); 236 | 237 | it('should handle invalid JSON response on failing response', function(done) { 238 | textResponse = 'Server error'; 239 | responseStatus = 400; 240 | var promise = ajax({ 241 | url: 'test', 242 | dataType: 'json', 243 | type: 'GET', 244 | success: function(response) { 245 | throw new Error('this request should fail'); 246 | }, 247 | error: function(error) { 248 | expect(error.response.status).to.equal(400); 249 | } 250 | }); 251 | 252 | promise 253 | .then(function() { 254 | throw new Error('this request should fail'); 255 | }) 256 | ['catch'](function(error) { 257 | expect(error.response.status).to.equal(400); 258 | done(); 259 | }) 260 | ['catch'](function(error) { 261 | done(error); 262 | }); 263 | }); 264 | 265 | it('should parse text as property of Error on failing request', function(done) { 266 | textResponse = 'Nope'; 267 | responseStatus = 400; 268 | var promise = ajax({ 269 | dataType: 'text', 270 | url: 'test', 271 | type: 'GET' 272 | }); 273 | 274 | promise 275 | .then(function() { 276 | throw new Error('this request should fail'); 277 | }) 278 | ['catch'](function(error) { 279 | expect(error.responseData).to.equal('Nope'); 280 | done(); 281 | }) 282 | ['catch'](function(error) { 283 | done(error); 284 | }); 285 | }); 286 | }); 287 | 288 | it.skip('should pass through network errors', function(done) { 289 | // Simulate a network error by not resolving the fetch promise 290 | textResponse = 'Network error'; 291 | responseStatus = 600; // Non-standard status code to simulate a network error 292 | var promise = ajax({ 293 | dataType: 'text', 294 | url: 'test', 295 | type: 'GET' 296 | }); 297 | 298 | promise 299 | .then(function() { 300 | throw new Error('this request should fail'); 301 | }) 302 | ['catch'](function(error) { 303 | expect(error).to.be.an['instanceof'](TypeError); 304 | expect(error).not.to.have.property('response'); 305 | expect(error.message).to.equal('Network request failed'); 306 | done(); 307 | }) 308 | ['catch'](function(error) { 309 | done(error); 310 | }); 311 | 312 | return promise; 313 | }); 314 | 315 | describe('Promise', function() { 316 | it('should return a Promise', function() { 317 | var xhr = ajax({ url: 'test', type: 'GET' }); 318 | expect(xhr).to.be.an['instanceof'](Promise); 319 | }); 320 | }); 321 | }); 322 | -------------------------------------------------------------------------------- /test/fetch/setup.js: -------------------------------------------------------------------------------- 1 | global.XMLHttpRequest = function() { 2 | this.withCredentials = true; 3 | }; 4 | 5 | global.self = {}; 6 | -------------------------------------------------------------------------------- /test/validation/attributesOption.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Setting options.attributes': { 3 | beforeEach: function() { 4 | @withValidation 5 | class Model extends Backbone.Model { 6 | static validation = { 7 | age: { 8 | required: true 9 | }, 10 | name: { 11 | required: true 12 | }, 13 | password: { 14 | required: true 15 | }, 16 | email: { 17 | pattern: 'email' 18 | } 19 | }; 20 | 21 | set(...args) { 22 | super.set(...args); 23 | return this.validationError === null; 24 | } 25 | } 26 | 27 | this.model = new Model(); 28 | }, 29 | 30 | 'through Model.validate options': { 31 | 'only the attributes in array should be validated': function() { 32 | var errors = this.model.validate(undefined, { 33 | attributes: ['name', 'age'] 34 | }); 35 | assert.defined(errors.name); 36 | assert.defined(errors.age); 37 | refute.defined(errors.password); 38 | refute.defined(errors.email); 39 | }, 40 | 41 | 'when all the attributes in array are valid': { 42 | beforeEach: function() { 43 | this.model.set({ 44 | age: 1, 45 | name: 'hello', 46 | email: 'invalidemail' 47 | }); 48 | }, 49 | 'validation will pass': function() { 50 | var errors = this.model.validate(undefined, { 51 | attributes: ['name', 'age'] 52 | }); 53 | refute.defined(errors); 54 | } 55 | } 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /test/validation/customCallbacks.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Overriding default callbacks in Backbone.Validation': { 3 | beforeEach: function() { 4 | this.originalOptions = {}; 5 | Object.assign(this.originalOptions, Validation.options); 6 | 7 | this.valid = sinon.spy(); 8 | this.invalid = sinon.spy(); 9 | 10 | Object.assign(Validation.options, { 11 | valid: this.valid, 12 | invalid: this.invalid 13 | }); 14 | 15 | @withValidation 16 | class Model extends Backbone.Model { 17 | static validation = { 18 | age: function(val) { 19 | if (val === 0) { 20 | return 'Age is invalid'; 21 | } 22 | } 23 | }; 24 | 25 | set(...args) { 26 | super.set(...args); 27 | return this.validationError === null; 28 | } 29 | } 30 | 31 | this.model = new Model(); 32 | }, 33 | 34 | afterEach: function() { 35 | Object.assign(Validation.options, this.originalOptions); 36 | }, 37 | 38 | 'validate should call overridden valid callback': function() { 39 | this.model.set( 40 | { 41 | age: 1 42 | }, 43 | { validate: true } 44 | ); 45 | 46 | assert.called(this.valid); 47 | }, 48 | 49 | 'validate should call overridden invalid callback': function() { 50 | this.model.set( 51 | { 52 | age: 0 53 | }, 54 | { validate: true } 55 | ); 56 | 57 | assert.called(this.invalid); 58 | } 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /test/validation/customPatterns.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Extending Backbone.Validation with custom pattern': { 3 | beforeEach: function() { 4 | Object.assign(Validation.patterns, { 5 | custom: /^test/ 6 | }); 7 | 8 | @withValidation 9 | class Model extends Backbone.Model { 10 | static validation = { 11 | name: { 12 | pattern: 'custom' 13 | } 14 | }; 15 | 16 | set(...args) { 17 | super.set(...args); 18 | return this.validationError === null; 19 | } 20 | } 21 | 22 | this.model = new Model(); 23 | }, 24 | 25 | 'should execute the custom pattern validator': function() { 26 | assert( 27 | this.model.set( 28 | { 29 | name: 'test' 30 | }, 31 | { validate: true } 32 | ) 33 | ); 34 | refute( 35 | this.model.set( 36 | { 37 | name: 'aa' 38 | }, 39 | { validate: true } 40 | ) 41 | ); 42 | } 43 | }, 44 | 45 | 'Overriding builtin pattern in Backbone.Validation': { 46 | beforeEach: function() { 47 | this.builtinEmail = Validation.patterns.email; 48 | 49 | Object.assign(Validation.patterns, { 50 | email: /^test/ 51 | }); 52 | 53 | @withValidation 54 | class Model extends Backbone.Model { 55 | static validation = { 56 | name: { 57 | pattern: 'email' 58 | } 59 | }; 60 | 61 | set(...args) { 62 | super.set(...args); 63 | return this.validationError === null; 64 | } 65 | } 66 | 67 | this.model = new Model(); 68 | }, 69 | 70 | afterEach: function() { 71 | Validation.patterns.email = this.builtinEmail; 72 | }, 73 | 74 | 'should execute the custom pattern validator': function() { 75 | assert( 76 | this.model.set( 77 | { 78 | name: 'test' 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | refute( 84 | this.model.set( 85 | { 86 | name: 'aa' 87 | }, 88 | { validate: true } 89 | ) 90 | ); 91 | } 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /test/validation/customValidators.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Extending Backbone.Validation with custom validator': { 3 | beforeEach: function() { 4 | var that = this; 5 | Object.assign(Validation.validators, { 6 | custom: function(value, attr, customValue) { 7 | that.context = this; 8 | if (value !== customValue) { 9 | return 'error'; 10 | } 11 | } 12 | }); 13 | 14 | @withValidation 15 | class Model extends Backbone.Model { 16 | static validation = { 17 | age: { 18 | custom: 1 19 | } 20 | }; 21 | 22 | set(...args) { 23 | super.set(...args); 24 | return this.validationError === null; 25 | } 26 | } 27 | 28 | this.model = new Model(); 29 | }, 30 | 31 | 'should execute the custom validator': function() { 32 | assert( 33 | this.model.set( 34 | { 35 | age: 1 36 | }, 37 | { validate: true } 38 | ) 39 | ); 40 | refute( 41 | this.model.set( 42 | { 43 | age: 2 44 | }, 45 | { validate: true } 46 | ) 47 | ); 48 | } 49 | }, 50 | 51 | 'Defining a custom validator as a string': { 52 | beforeEach: function() { 53 | @withValidation 54 | class Model extends Backbone.Model { 55 | static validation = { 56 | age: 'validateAge' 57 | }; 58 | 59 | validateAge(value, attr, computedState) { 60 | if (value != 1) return 'Age invalid'; 61 | } 62 | 63 | set(...args) { 64 | super.set(...args); 65 | return this.validationError === null; 66 | } 67 | } 68 | 69 | this.model = new Model(); 70 | 71 | this.validateAgeSpy = sinon.spy(this.model, 'validateAge'); 72 | }, 73 | 74 | 'should execute corresponding method in model': function() { 75 | assert( 76 | this.model.set( 77 | { 78 | age: 1 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | sinon.assert.calledOnce(this.validateAgeSpy); 84 | assert( 85 | this.model.set( 86 | { 87 | age: '1' 88 | }, 89 | { validate: true } 90 | ) 91 | ); 92 | sinon.assert.calledTwice(this.validateAgeSpy); 93 | refute( 94 | this.model.set( 95 | { 96 | age: 2 97 | }, 98 | { validate: true } 99 | ) 100 | ); 101 | sinon.assert.calledThrice(this.validateAgeSpy); 102 | } 103 | }, 104 | 105 | 'Defining a custom validator as a string array': { 106 | beforeEach: function() { 107 | @withValidation 108 | class Model extends Backbone.Model { 109 | static validation = { 110 | age: ['validateAge', 'validateNumber'] 111 | }; 112 | 113 | validateAge(value, attr, computedState) { 114 | if (value != 1) return 'Age invalid'; 115 | } 116 | 117 | validateNumber(value, attr, computedState) { 118 | if (typeof value !== 'number') return 'Not a number'; 119 | } 120 | 121 | set(...args) { 122 | super.set(...args); 123 | return this.validationError === null; 124 | } 125 | } 126 | 127 | this.model = new Model(); 128 | 129 | this.validateAgeSpy = sinon.spy(this.model, 'validateAge'); 130 | this.validateNumberSpy = sinon.spy(this.model, 'validateNumber'); 131 | }, 132 | 133 | 'should use corresponding methods in model': function() { 134 | assert( 135 | this.model.set( 136 | { 137 | age: 1 138 | }, 139 | { validate: true } 140 | ) 141 | ); 142 | sinon.assert.calledOnce(this.validateAgeSpy); 143 | sinon.assert.calledOnce(this.validateNumberSpy); 144 | refute( 145 | this.model.set( 146 | { 147 | age: '1' 148 | }, 149 | { validate: true } 150 | ) 151 | ); 152 | sinon.assert.calledTwice(this.validateAgeSpy); 153 | sinon.assert.calledTwice(this.validateNumberSpy); 154 | } 155 | }, 156 | 157 | 'Overriding built-in validator in Backbone.Validation': { 158 | beforeEach: function() { 159 | this.builtinMin = Validation.validators.min; 160 | 161 | Object.assign(Validation.validators, { 162 | min: function(value, attr, customValue) { 163 | if (value !== customValue) { 164 | return 'error'; 165 | } 166 | } 167 | }); 168 | 169 | @withValidation 170 | class Model extends Backbone.Model { 171 | static validation = { 172 | age: { 173 | min: 1 174 | } 175 | }; 176 | 177 | set(...args) { 178 | super.set(...args); 179 | return this.validationError === null; 180 | } 181 | } 182 | 183 | this.model = new Model(); 184 | }, 185 | 186 | afterEach: function() { 187 | Validation.validators.min = this.builtinMin; 188 | }, 189 | 190 | 'should execute the overridden validator': function() { 191 | assert( 192 | this.model.set( 193 | { 194 | age: 1 195 | }, 196 | { validate: true } 197 | ) 198 | ); 199 | refute( 200 | this.model.set( 201 | { 202 | age: 2 203 | }, 204 | { validate: true } 205 | ) 206 | ); 207 | } 208 | }, 209 | 210 | 'Chaining built-in validators with custom': { 211 | beforeEach: function() { 212 | Object.assign(Validation.validators, { 213 | custom2: function(value, attr, customValue, model) { 214 | if (value !== customValue) { 215 | return 'error'; 216 | } 217 | }, 218 | custom: function(value, attr, customValue, model) { 219 | return ( 220 | this.required(value, attr, true, model) || this.custom2(value, attr, customValue, model) 221 | ); 222 | } 223 | }); 224 | 225 | @withValidation 226 | class Model extends Backbone.Model { 227 | static validation = { 228 | name: { 229 | custom: 'custom' 230 | } 231 | }; 232 | 233 | set(...args) { 234 | super.set(...args); 235 | return this.validationError === null; 236 | } 237 | } 238 | 239 | this.model = new Model(); 240 | }, 241 | 242 | 'violating first validator in chain return first error message': function() { 243 | assert.equals({ name: 'Name is required' }, this.model.validate({ name: '' })); 244 | }, 245 | 246 | 'violating second validator in chain return second error message': function() { 247 | assert.equals({ name: 'error' }, this.model.validate({ name: 'a' })); 248 | }, 249 | 250 | 'violating none of the validators returns undefined': function() { 251 | refute.defined(this.model.validate({ name: 'custom' })); 252 | } 253 | }, 254 | 255 | 'Formatting custom validator messages': { 256 | beforeEach: function() { 257 | Object.assign(Validation.validators, { 258 | custom: function(value, attr, customValue, model) { 259 | if (value !== customValue) { 260 | return this.format( 261 | '{0} must be equal to {1}', 262 | this.formatLabel(attr, model), 263 | customValue 264 | ); 265 | } 266 | } 267 | }); 268 | 269 | @withValidation 270 | class Model extends Backbone.Model { 271 | static validation = { 272 | name: { 273 | custom: 'custom' 274 | } 275 | }; 276 | 277 | set(...args) { 278 | super.set(...args); 279 | return this.validationError === null; 280 | } 281 | } 282 | 283 | this.model = new Model(); 284 | }, 285 | 286 | 'a custom validator can return a formatted message': function() { 287 | assert.equals({ name: 'Name must be equal to custom' }, this.model.validate({ name: '' })); 288 | } 289 | } 290 | }; 291 | -------------------------------------------------------------------------------- /test/validation/errorMessages.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Specifying error messages': { 3 | beforeEach: function() { 4 | this.invalid = sinon.spy(); 5 | }, 6 | 7 | 'per validator': { 8 | beforeEach: function() { 9 | @withValidation 10 | class Model extends Backbone.Model { 11 | static validation = { 12 | email: [ 13 | { 14 | required: true, 15 | msg: 'required' 16 | }, 17 | { 18 | pattern: 'email', 19 | msg: function() { 20 | return 'pattern'; 21 | } 22 | } 23 | ] 24 | }; 25 | 26 | set(...args) { 27 | super.set(...args); 28 | return this.validationError === null; 29 | } 30 | } 31 | this.model = new Model(); 32 | }, 33 | 34 | 'and violating first validator returns msg specified for first validator': function() { 35 | this.model.set({ email: '' }, { validate: true, invalid: this.invalid }); 36 | 37 | assert(this.invalid.calledWith('email', 'required')); 38 | }, 39 | 40 | 'and violating second validator returns msg specified for second validator': function() { 41 | this.model.set({ email: 'a' }, { validate: true, invalid: this.invalid }); 42 | 43 | assert(this.invalid.calledWith('email', 'pattern')); 44 | } 45 | }, 46 | 47 | 'per attribute': { 48 | beforeEach: function() { 49 | @withValidation 50 | class Model extends Backbone.Model { 51 | static validation = { 52 | email: { 53 | required: true, 54 | pattern: 'email', 55 | msg: 'error' 56 | } 57 | }; 58 | 59 | set(...args) { 60 | super.set(...args); 61 | return this.validationError === null; 62 | } 63 | } 64 | this.model = new Model(); 65 | }, 66 | 67 | 'and violating first validator returns msg specified for attribute': function() { 68 | this.model.set({ email: '' }, { validate: true, invalid: this.invalid }); 69 | 70 | assert(this.invalid.calledWith('email', 'error')); 71 | }, 72 | 73 | 'and violating second validator returns msg specified for attribute': function() { 74 | this.model.set({ email: 'a' }, { validate: true, invalid: this.invalid }); 75 | 76 | assert(this.invalid.calledWith('email', 'error')); 77 | } 78 | } 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /test/validation/events.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Backbone.Validation events': { 3 | beforeEach: function() { 4 | @withValidation 5 | class Model extends Backbone.Model { 6 | static validation = { 7 | age: function(val) { 8 | if (!val) { 9 | return 'age'; 10 | } 11 | }, 12 | name: function(val) { 13 | if (!val) { 14 | return 'name'; 15 | } 16 | } 17 | }; 18 | 19 | set(...args) { 20 | super.set(...args); 21 | return this.validationError === null; 22 | } 23 | } 24 | 25 | this.model = new Model(); 26 | }, 27 | 28 | 'model is updated after the validated event is raised': function() { 29 | this.model.on( 30 | 'change', 31 | function() { 32 | assert.equals(1, this.model.get('age')); 33 | }, 34 | this 35 | ); 36 | 37 | this.model.on( 38 | 'validated', 39 | function() { 40 | refute.defined(this.model.get('age')); 41 | }, 42 | this 43 | ); 44 | 45 | this.model.set( 46 | { 47 | age: 1, 48 | name: 'name' 49 | }, 50 | { validate: true } 51 | ); 52 | }, 53 | 54 | 'when model is valid': { 55 | 'validated event is triggered with model and null as errors': function(done) { 56 | this.model.once( 57 | 'validated', 58 | function(model, errors) { 59 | assert.same(null, errors); 60 | assert.same(this.model, model); 61 | done(); 62 | }, 63 | this 64 | ); 65 | 66 | this.model.set( 67 | { 68 | age: 1, 69 | name: 'name' 70 | }, 71 | { validate: true } 72 | ); 73 | } 74 | }, 75 | 76 | 'when one invalid value is set': { 77 | 'validated event is triggered with model and an object with the names of the attributes with error': function( 78 | done 79 | ) { 80 | this.model.on( 81 | 'validated', 82 | function(model, attr) { 83 | assert.same(this.model, model); 84 | assert.equals({ age: 'age', name: 'name' }, attr); 85 | done(); 86 | }, 87 | this 88 | ); 89 | 90 | this.model.set({ age: 0 }, { validate: true }); 91 | }, 92 | 93 | 'invalid event is triggered with model and an object with the names of the attributes with error': function( 94 | done 95 | ) { 96 | this.model.on( 97 | 'invalid', 98 | function(model, attr) { 99 | assert.same(this.model, model); 100 | assert.equals({ age: 'age', name: 'name' }, attr); 101 | done(); 102 | }, 103 | this 104 | ); 105 | 106 | this.model.set({ age: 0 }, { validate: true }); 107 | } 108 | }, 109 | 110 | 'when one valid value is set': { 111 | 'validated event is triggered with model and an object with the names of the attributes with error': function( 112 | done 113 | ) { 114 | this.model.on( 115 | 'validated', 116 | function(model, attrs) { 117 | assert.same(this.model, model); 118 | assert.equals({ name: 'name' }, attrs); 119 | done(); 120 | }, 121 | this 122 | ); 123 | 124 | this.model.set( 125 | { 126 | age: 1 127 | }, 128 | { validate: true } 129 | ); 130 | } 131 | } 132 | } 133 | }; 134 | -------------------------------------------------------------------------------- /test/validation/general.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Backbone.Validation': { 3 | beforeEach: function() { 4 | @withValidation 5 | class Model extends Backbone.Model { 6 | static validation = { 7 | age: function(val) { 8 | if (!val) { 9 | return 'Age is invalid'; 10 | } 11 | }, 12 | name: function(val) { 13 | if (!val) { 14 | return 'Name is invalid'; 15 | } 16 | } 17 | }; 18 | 19 | set(...args) { 20 | super.set(...args); 21 | return this.validationError ? null : this; 22 | } 23 | } 24 | 25 | this.model = new Model(); 26 | }, 27 | 28 | 'when bound to model with two validated attributes': { 29 | beforeEach: function() {}, 30 | 31 | 'attribute without validator should be set sucessfully': function() { 32 | assert( 33 | this.model.set( 34 | { 35 | someProperty: true 36 | }, 37 | { validate: true } 38 | ) 39 | ); 40 | }, 41 | 42 | 'and setting': { 43 | 'one valid value': { 44 | beforeEach: function() { 45 | this.model.set( 46 | { 47 | age: 1 48 | }, 49 | { validate: true } 50 | ); 51 | }, 52 | 53 | 'should return the model': function() { 54 | assert.same( 55 | this.model.set( 56 | { 57 | age: 1 58 | }, 59 | { validate: true } 60 | ), 61 | this.model 62 | ); 63 | }, 64 | 65 | 'should update the model': function() { 66 | assert.equals(this.model.get('age'), 1); 67 | }, 68 | 69 | 'model should be invalid': function() { 70 | refute(this.model.isValid()); 71 | } 72 | }, 73 | 74 | 'one invalid value': { 75 | beforeEach: function() { 76 | this.model.set( 77 | { 78 | age: 0 79 | }, 80 | { validate: true } 81 | ); 82 | }, 83 | 84 | 'should return false': function() { 85 | refute( 86 | this.model.set( 87 | { 88 | age: 0 89 | }, 90 | { validate: true } 91 | ) 92 | ); 93 | }, 94 | 95 | 'should update the model': function() { 96 | assert.equals(this.model.get('age'), 0); 97 | }, 98 | 99 | 'model should be invalid': function() { 100 | refute(this.model.isValid()); 101 | } 102 | }, 103 | 104 | 'two valid values': { 105 | beforeEach: function() { 106 | this.model.set( 107 | { 108 | age: 1, 109 | name: 'hello' 110 | }, 111 | { validate: true } 112 | ); 113 | }, 114 | 115 | 'model should be valid': function() { 116 | assert(this.model.isValid()); 117 | } 118 | }, 119 | 120 | 'two invalid values': { 121 | beforeEach: function() { 122 | this.model.set( 123 | { 124 | age: 0, 125 | name: '' 126 | }, 127 | { validate: true } 128 | ); 129 | }, 130 | 131 | 'model should be invalid': function() { 132 | refute(this.model.isValid()); 133 | } 134 | }, 135 | 136 | 'first value invalid and second value valid': { 137 | beforeEach: function() { 138 | this.result = this.model.set( 139 | { 140 | age: 1, 141 | name: '' 142 | }, 143 | { validate: true } 144 | ); 145 | }, 146 | 147 | 'model is not updated': function() { 148 | refute(this.result); 149 | }, 150 | 151 | 'model should be invalid': function() { 152 | refute(this.model.isValid()); 153 | } 154 | }, 155 | 156 | 'first value valid and second value invalid': { 157 | beforeEach: function() { 158 | this.result = this.model.set( 159 | { 160 | age: 0, 161 | name: 'name' 162 | }, 163 | { validate: true } 164 | ); 165 | }, 166 | 167 | 'model is not updated': function() { 168 | refute(this.result); 169 | }, 170 | 171 | 'model should be invalid': function() { 172 | refute(this.model.isValid()); 173 | } 174 | }, 175 | 176 | 'one value at a time correctly marks the model as either valid or invalid': function() { 177 | refute(this.model.isValid()); 178 | 179 | this.model.set( 180 | { 181 | age: 0 182 | }, 183 | { validate: true } 184 | ); 185 | refute(this.model.isValid()); 186 | 187 | this.model.set( 188 | { 189 | age: 1 190 | }, 191 | { validate: true } 192 | ); 193 | refute(this.model.isValid()); 194 | 195 | this.model.set( 196 | { 197 | name: 'hello' 198 | }, 199 | { validate: true } 200 | ); 201 | assert(this.model.isValid()); 202 | 203 | this.model.set( 204 | { 205 | age: 0 206 | }, 207 | { validate: true } 208 | ); 209 | refute(this.model.isValid()); 210 | } 211 | }, 212 | 213 | 'and validate is explicitly called with no parameters': { 214 | beforeEach: function() { 215 | this.invalid = sinon.spy(); 216 | this.valid = sinon.spy(); 217 | @withValidation 218 | class Model extends Backbone.Model { 219 | static validation = { 220 | age: { 221 | min: 1, 222 | msg: 'error' 223 | }, 224 | name: { 225 | required: true, 226 | msg: 'error' 227 | } 228 | }; 229 | 230 | set(...args) { 231 | super.set(...args); 232 | return this.validationError === null; 233 | } 234 | } 235 | this.model = new Model(); 236 | }, 237 | 238 | 'all attributes on the model is validated when nothing has been set': function() { 239 | this.model.validate(undefined, { 240 | valid: this.valid, 241 | invalid: this.invalid 242 | }); 243 | 244 | assert.calledWith(this.invalid, 'age', 'error'); 245 | assert.calledWith(this.invalid, 'name', 'error'); 246 | }, 247 | 248 | 'all attributes on the model is validated when one property has been set without validating': function() { 249 | this.model.set({ age: 1 }); 250 | 251 | this.model.validate(undefined, { 252 | valid: this.valid, 253 | invalid: this.invalid 254 | }); 255 | 256 | assert.calledWith(this.valid, 'age'); 257 | assert.calledWith(this.invalid, 'name', 'error'); 258 | }, 259 | 260 | 'all attributes on the model is validated when two properties has been set without validating': function() { 261 | this.model.set({ age: 1, name: 'name' }); 262 | 263 | this.model.validate(undefined, { 264 | valid: this.valid, 265 | invalid: this.invalid 266 | }); 267 | 268 | assert.calledWith(this.valid, 'age'); 269 | assert.calledWith(this.valid, 'name'); 270 | }, 271 | 272 | 'callbacks are not called for unvalidated attributes': function() { 273 | this.model.set({ age: 1, name: 'name', someProp: 'some value' }); 274 | 275 | this.model.validate(undefined, { 276 | valid: this.valid, 277 | invalid: this.invalid 278 | }); 279 | 280 | assert.calledWith(this.valid, 'age'); 281 | assert.calledWith(this.valid, 'name'); 282 | refute.calledWith(this.valid, 'someProp'); 283 | } 284 | } 285 | }, 286 | 287 | 'when bound to model with three validators on one attribute': { 288 | beforeEach: function() { 289 | @withValidation 290 | class Model extends Backbone.Model { 291 | static validation = { 292 | postalCode: { 293 | minLength: 2, 294 | pattern: 'digits', 295 | maxLength: 4 296 | } 297 | }; 298 | } 299 | 300 | this.model = new Model(); 301 | }, 302 | 303 | 'and violating the first validator the model is invalid': function() { 304 | this.model.set({ postalCode: '1' }, { validate: true }); 305 | 306 | refute(this.model.isValid()); 307 | }, 308 | 309 | 'and violating the second validator the model is invalid': function() { 310 | this.model.set({ postalCode: 'ab' }, { validate: true }); 311 | 312 | refute(this.model.isValid()); 313 | }, 314 | 315 | 'and violating the last validator the model is invalid': function() { 316 | this.model.set({ postalCode: '12345' }, { validate: true }); 317 | 318 | refute(this.model.isValid()); 319 | }, 320 | 321 | 'and conforming to all validators the model is valid': function() { 322 | this.model.set({ postalCode: '123' }, { validate: true }); 323 | 324 | assert(this.model.isValid()); 325 | } 326 | }, 327 | 328 | 'when bound to model with two dependent attribute validations': { 329 | beforeEach: function() { 330 | @withValidation 331 | class Model extends Backbone.Model { 332 | static validation = { 333 | one: function(val, attr, computed) { 334 | if (val < computed.two) { 335 | return 'error'; 336 | } 337 | }, 338 | two: function(val, attr, computed) { 339 | if (val > computed.one) { 340 | return 'error'; 341 | } 342 | } 343 | }; 344 | 345 | set(...args) { 346 | super.set(...args); 347 | return this.validationError === null; 348 | } 349 | } 350 | 351 | this.model = new Model(); 352 | this.valid = sinon.spy(); 353 | this.invalid = sinon.spy(); 354 | }, 355 | 356 | 'when setting invalid value on second input': { 357 | beforeEach: function() { 358 | this.model.set({ one: 1 }, { validate: true, valid: this.valid, invalid: this.invalid }); 359 | this.model.set({ two: 2 }, { validate: true, valid: this.valid, invalid: this.invalid }); 360 | }, 361 | 362 | 'first input is valid': function() { 363 | assert.calledWith(this.invalid, 'one', 'error'); 364 | }, 365 | 366 | 'second input is invalid': function() { 367 | assert.calledWith(this.invalid, 'two', 'error'); 368 | } 369 | }, 370 | 371 | 'when setting invalid value on second input and changing first': { 372 | beforeEach: function() { 373 | this.model.set({ one: 1 }, { validate: true, valid: this.valid, invalid: this.invalid }); 374 | this.model.set({ two: 2 }, { validate: true, valid: this.valid, invalid: this.invalid }); 375 | this.model.set({ one: 2 }, { validate: true, valid: this.valid, invalid: this.invalid }); 376 | }, 377 | 378 | 'first input is valid': function() { 379 | assert.calledWith(this.valid, 'one'); 380 | }, 381 | 382 | 'second input is valid': function() { 383 | assert.calledWith(this.valid, 'two'); 384 | } 385 | } 386 | }, 387 | 388 | 'when bound to model with custom toJSON': { 389 | beforeEach: function() { 390 | this.model.toJSON = function() { 391 | return { 392 | person: { 393 | age: this.attributes.age, 394 | name: this.attributes.name 395 | } 396 | }; 397 | }; 398 | }, 399 | 400 | 'and conforming to all validators the model is valid': function() { 401 | this.model.set({ age: 12 }, { validate: true }); 402 | this.model.set({ name: 'Jack' }, { validate: true }); 403 | 404 | this.model.validate(); 405 | assert(this.model.isValid()); 406 | } 407 | }, 408 | 409 | 'when bound to model without validation rules': { 410 | beforeEach: function() { 411 | @withValidation 412 | class Model extends Backbone.Model {} 413 | 414 | this.model = new Model(); 415 | }, 416 | 417 | isValid: function() { 418 | assert(this.model.isValid()); 419 | }, 420 | 421 | validate: function() { 422 | refute(this.model.validate()); 423 | }, 424 | 425 | preValidate: function() { 426 | refute(this.model.preValidate()); 427 | } 428 | } 429 | } 430 | }; 431 | -------------------------------------------------------------------------------- /test/validation/isValid.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | isValid: { 3 | 'when model has not defined any validation': { 4 | beforeEach: function() { 5 | this.model = new Backbone.Model(); 6 | }, 7 | 8 | 'returns true': function() { 9 | assert.equals(this.model.isValid(), true); 10 | } 11 | }, 12 | 13 | 'when model has defined validation': { 14 | beforeEach: function() { 15 | @withValidation 16 | class Model extends Backbone.Model { 17 | static validation = { 18 | name: { 19 | required: true 20 | } 21 | }; 22 | } 23 | 24 | this.model = new Model(); 25 | }, 26 | 27 | 'returns true when model is valid': function() { 28 | this.model.set({ name: 'name' }); 29 | 30 | assert.equals(this.model.isValid(), true); 31 | }, 32 | 33 | 'returns false when model is invalid': function() { 34 | assert.equals(this.model.isValid(), false); 35 | 36 | this.model.set({ name: '' }); 37 | 38 | assert.equals(this.model.isValid(), false); 39 | }, 40 | 41 | 'set validationError when model is invalid': function() { 42 | this.model.set({ name: '' }); 43 | 44 | this.model.isValid(); 45 | 46 | assert(this.model.validationError); 47 | assert(this.model.validationError.name); 48 | }, 49 | 50 | 'invalid is triggered when model is invalid': function(done) { 51 | this.model.on('invalid', function(model, attrs) { 52 | done(); 53 | }); 54 | refute(this.model.isValid()); 55 | }, 56 | 57 | 'and passing name of attribute': { 58 | beforeEach: function() { 59 | @withValidation 60 | class Model extends Backbone.Model { 61 | static validation = { 62 | name: { 63 | required: true 64 | }, 65 | age: { 66 | required: true 67 | } 68 | }; 69 | } 70 | this.model = new Model(); 71 | }, 72 | 73 | 'returns false when attribute is invalid': function() { 74 | refute(this.model.isValid('name')); 75 | }, 76 | 77 | 'invalid is triggered when attribute is invalid': function(done) { 78 | this.model.on('invalid', function(model, attrs) { 79 | done(); 80 | }); 81 | refute(this.model.isValid('name')); 82 | }, 83 | 84 | 'returns true when attribute is valid': function() { 85 | this.model.set({ name: 'name' }); 86 | 87 | assert.equals(this.model.isValid('name'), true); 88 | } 89 | }, 90 | 91 | 'and passing array of attributes': { 92 | beforeEach: function() { 93 | @withValidation 94 | class Model extends Backbone.Model { 95 | static validation = { 96 | name: { 97 | required: true 98 | }, 99 | age: { 100 | required: true 101 | }, 102 | phone: { 103 | required: true 104 | } 105 | }; 106 | } 107 | this.model = new Model(); 108 | }, 109 | 110 | 'returns false when all attributes are invalid': function() { 111 | refute(this.model.isValid(['name', 'age'])); 112 | }, 113 | 114 | 'returns false when one attribute is invalid': function() { 115 | this.model.set({ name: 'name' }); 116 | 117 | refute(this.model.isValid(['name', 'age'])); 118 | }, 119 | 120 | 'returns true when all attributes are valid': function() { 121 | this.model.set({ name: 'name', age: 1 }); 122 | 123 | assert.equals(this.model.isValid(['name', 'age']), true); 124 | } 125 | } 126 | } 127 | } 128 | }; 129 | -------------------------------------------------------------------------------- /test/validation/labelFormatter.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Label formatters': { 3 | 'Attribute names on the model can be formatted in error messages using': { 4 | beforeEach: function() { 5 | @withValidation 6 | class Model extends Backbone.Model { 7 | static validation = { 8 | someAttribute: { 9 | required: true 10 | }, 11 | some_attribute: { 12 | required: true 13 | }, 14 | some_other_attribute: { 15 | required: true 16 | } 17 | }; 18 | 19 | labels = { 20 | someAttribute: 'Custom label' 21 | }; 22 | } 23 | this.model = new Model(); 24 | }, 25 | 26 | afterEach: function() { 27 | // Reset to default formatter 28 | Validation.options.labelFormatter = 'sentenceCase'; 29 | }, 30 | 31 | 'no formatting': { 32 | beforeEach: function() { 33 | Validation.options.labelFormatter = 'none'; 34 | }, 35 | 36 | 'returns the attribute name': function() { 37 | assert.equals('someAttribute is required', this.model.preValidate('someAttribute', '')); 38 | } 39 | }, 40 | 41 | 'label formatting': { 42 | beforeEach: function() { 43 | Validation.options.labelFormatter = 'label'; 44 | }, 45 | 46 | 'looks up a label on the model': function() { 47 | assert.equals('Custom label is required', this.model.preValidate('someAttribute', '')); 48 | }, 49 | 50 | 'returns sentence cased name when label is not found': function() { 51 | assert.equals('Some attribute is required', this.model.preValidate('some_attribute', '')); 52 | }, 53 | 54 | 'returns sentence cased name when label attribute is not defined': function() { 55 | @withValidation 56 | class Model extends Backbone.Model { 57 | static validation = { 58 | someAttribute: { 59 | required: true 60 | } 61 | }; 62 | 63 | set(...args) { 64 | super.set(...args); 65 | return this.validationError === null; 66 | } 67 | } 68 | 69 | var model = new Model(); 70 | assert.equals('Some attribute is required', model.preValidate('someAttribute', '')); 71 | } 72 | }, 73 | 74 | 'sentence formatting': { 75 | beforeEach: function() { 76 | Validation.options.labelFormatter = 'sentenceCase'; 77 | }, 78 | 79 | 'sentence cases camel cased attribute name': function() { 80 | assert.equals('Some attribute is required', this.model.preValidate('someAttribute', '')); 81 | }, 82 | 83 | 'sentence cases underscore named attribute name': function() { 84 | assert.equals('Some attribute is required', this.model.preValidate('some_attribute', '')); 85 | }, 86 | 87 | 'sentence cases underscore named attribute name with multiple underscores': function() { 88 | assert.equals( 89 | 'Some other attribute is required', 90 | this.model.preValidate('some_other_attribute', '') 91 | ); 92 | } 93 | } 94 | } 95 | } 96 | }; 97 | -------------------------------------------------------------------------------- /test/validation/mixin.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Mixin validation': { 3 | beforeEach: function() { 4 | @withValidation 5 | class Model extends Backbone.Model { 6 | static validation = { 7 | name: function(val) { 8 | if (!val) { 9 | return 'error'; 10 | } 11 | } 12 | }; 13 | 14 | set(...args) { 15 | super.set(...args); 16 | return this.validationError === null; 17 | } 18 | } 19 | 20 | this.model = new Model(); 21 | }, 22 | 23 | 'isValid is false when model is invalid': function() { 24 | assert.equals(false, this.model.isValid(true)); 25 | }, 26 | 27 | 'isValid is true when model is valid': function() { 28 | this.model.set({ name: 'name' }); 29 | 30 | assert.equals(true, this.model.isValid(true)); 31 | }, 32 | 33 | 'refutes setting invalid value': function() { 34 | refute(this.model.set({ name: '' }, { validate: true })); 35 | }, 36 | 37 | 'succeeds setting valid value': function() { 38 | assert(this.model.set({ name: 'name' }, { validate: true })); 39 | }, 40 | 41 | 'when setting attribute on model without validation': { 42 | beforeEach: function() { 43 | this.model = new Backbone.Model(); 44 | }, 45 | 46 | 'it should not complain': function() { 47 | assert(this.model.set({ someAttr: 'someValue' }, { validate: true })); 48 | } 49 | } 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /test/validation/preValidate.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preValidate: { 3 | 'when model has not defined any validation': { 4 | beforeEach: function() { 5 | @withValidation 6 | class Model extends Backbone.Model { 7 | static validation = {}; 8 | set(...args) { 9 | super.set(...args); 10 | return this.validationError === null; 11 | } 12 | } 13 | this.model = new Model(); 14 | }, 15 | 16 | 'returns nothing': function() { 17 | refute(this.model.preValidate('attr', 'value')); 18 | } 19 | }, 20 | 21 | 'when model has defined validation': { 22 | beforeEach: function() { 23 | @withValidation 24 | class Model extends Backbone.Model { 25 | static validation = { 26 | name: { 27 | required: true 28 | }, 29 | address: { 30 | required: true 31 | }, 32 | authenticated: { 33 | required: false 34 | } 35 | }; 36 | 37 | set(...args) { 38 | super.set(...args); 39 | return this.validationError === null; 40 | } 41 | } 42 | 43 | this.model = new Model(); 44 | }, 45 | 46 | 'and pre-validating single attribute': { 47 | 'returns error message when value is not valid': function() { 48 | assert(this.model.preValidate('name', '')); 49 | }, 50 | 51 | 'returns nothing when value is valid': function() { 52 | refute(this.model.preValidate('name', 'name')); 53 | }, 54 | 55 | 'returns nothing when attribute pre-validated has no validation': function() { 56 | refute(this.model.preValidate('age', 2)); 57 | }, 58 | 59 | 'handles null value': function() { 60 | refute(this.model.preValidate('authenticated', null)); 61 | } 62 | }, 63 | 64 | 'and pre-validating hash of attributes': { 65 | 'returns error object when value is not valid': function() { 66 | var result = this.model.preValidate({ name: '', address: 'address' }); 67 | assert(result.name); 68 | refute(result.address); 69 | }, 70 | 71 | 'returns error object when values are not valid': function() { 72 | var result = this.model.preValidate({ name: '', address: '' }); 73 | assert(result.name); 74 | assert(result.address); 75 | }, 76 | 77 | 'returns nothing when value is valid': function() { 78 | refute(this.model.preValidate({ name: 'name' })); 79 | } 80 | } 81 | }, 82 | 83 | 'when model has dependancies between validation functions': { 84 | setUp: function() { 85 | var CARD_TYPES = { 86 | VISA: 0, 87 | AMEX: 1 88 | }; 89 | 90 | @withValidation 91 | class Model extends Backbone.Model { 92 | static validation = { 93 | card_type: { 94 | required: true 95 | }, 96 | security_code: function(value, attr, computedState) { 97 | var requiredLength = computedState.card_type === CARD_TYPES.AMEX ? 4 : 3; 98 | if (value && typeof value === 'string' && value.length !== requiredLength) { 99 | return 'Please enter a valid security code.'; 100 | } 101 | } 102 | }; 103 | 104 | set(...args) { 105 | super.set(...args); 106 | return this.validationError === null; 107 | } 108 | } 109 | 110 | Model.CARD_TYPES = CARD_TYPES; 111 | this.ModelDefinition = Model; 112 | this.model = new Model(); 113 | }, 114 | 115 | 'and pre-validating hash of attributes': { 116 | 'returns error object when value is not valid': function() { 117 | var result = this.model.preValidate({ 118 | card_type: this.ModelDefinition.CARD_TYPES.VISA, 119 | security_code: '1234' 120 | }); 121 | assert(result.security_code); 122 | refute(result.card_type); 123 | }, 124 | 125 | 'returns error object when values are not valid': function() { 126 | var result = this.model.preValidate({ 127 | card_type: '', 128 | security_code: '12345' 129 | }); 130 | assert(result.card_type); 131 | assert(result.security_code); 132 | }, 133 | 134 | 'returns nothing when value is valid': function() { 135 | refute( 136 | this.model.preValidate({ 137 | card_type: this.ModelDefinition.CARD_TYPES.AMEX, 138 | security_code: '1234' 139 | }) 140 | ); 141 | } 142 | } 143 | } 144 | } 145 | }; 146 | -------------------------------------------------------------------------------- /test/validation/setup-globals.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import * as Backbone from '../../nextbone.js'; 3 | import * as Validation from '../../validation.js'; 4 | import sinon from 'sinon'; 5 | 6 | global.sinon = sinon; 7 | global.Backbone = Backbone; 8 | 9 | global.Validation = Validation; 10 | global.withValidation = Validation.withValidation; 11 | 12 | global.assert = assert; 13 | 14 | assert.defined = assert.isDefined; 15 | assert.equals = assert.deepEqual; 16 | assert.contains = assert.include; 17 | assert.same = assert.strictEqual; 18 | assert.exception = assert['throws']; 19 | assert.called = sinon.assert.called; 20 | assert.calledWith = sinon.assert.calledWith; 21 | 22 | global.refute = assert.isNotOk; 23 | refute.contains = assert.notInclude; 24 | refute.defined = assert.isUndefined; 25 | refute.same = assert.notStrictEqual; 26 | refute.exception = assert.doesNotThrow; 27 | refute.calledWith = sinon.assert.neverCalledWith; 28 | -------------------------------------------------------------------------------- /test/validation/validators/acceptance.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'acceptance validator': { 3 | beforeEach: function() { 4 | @withValidation 5 | class Model extends Backbone.Model { 6 | static validation = { 7 | agree: { 8 | acceptance: true 9 | } 10 | }; 11 | 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | 18 | this.model = new Model(); 19 | }, 20 | 21 | 'has default error message': function(done) { 22 | this.model.on('validated', function(model, error) { 23 | assert.equals({ agree: 'Agree must be accepted' }, error); 24 | done(); 25 | }); 26 | this.model.set({ agree: false }, { validate: true }); 27 | }, 28 | 29 | 'non-boolean is invalid': function() { 30 | refute( 31 | this.model.set( 32 | { 33 | agree: 'non-boolean' 34 | }, 35 | { validate: true } 36 | ) 37 | ); 38 | }, 39 | 40 | 'string with true is evaluated as valid': function() { 41 | assert( 42 | this.model.set( 43 | { 44 | agree: 'true' 45 | }, 46 | { validate: true } 47 | ) 48 | ); 49 | }, 50 | 51 | 'false boolean is invalid': function() { 52 | refute( 53 | this.model.set( 54 | { 55 | agree: false 56 | }, 57 | { validate: true } 58 | ) 59 | ); 60 | }, 61 | 62 | 'true boolean is valid': function() { 63 | assert( 64 | this.model.set( 65 | { 66 | agree: true 67 | }, 68 | { validate: true } 69 | ) 70 | ); 71 | } 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /test/validation/validators/equalTo.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'equalTo validator': { 3 | beforeEach: function() { 4 | var that = this; 5 | 6 | @withValidation 7 | class Model extends Backbone.Model { 8 | static validation = { 9 | password: { 10 | required: true 11 | }, 12 | passwordRepeat: { 13 | equalTo: 'password' 14 | } 15 | }; 16 | 17 | set(...args) { 18 | super.set(...args); 19 | return this.validationError === null; 20 | } 21 | } 22 | 23 | this.model = new Model(); 24 | 25 | this.model.set({ password: 'password' }); 26 | }, 27 | 28 | 'has default error message': function(done) { 29 | this.model.on('validated', function(model, error) { 30 | assert.equals({ passwordRepeat: 'Password repeat must be the same as Password' }, error); 31 | done(); 32 | }); 33 | this.model.set({ passwordRepeat: '123' }, { validate: true }); 34 | }, 35 | 36 | 'value equal to (===) the specified attribute is valid': function() { 37 | assert( 38 | this.model.set( 39 | { 40 | passwordRepeat: 'password' 41 | }, 42 | { validate: true } 43 | ) 44 | ); 45 | }, 46 | 47 | 'value not equal to (!==) the specified attribute is invalid': function() { 48 | refute( 49 | this.model.set( 50 | { 51 | passwordRepeat: 'error' 52 | }, 53 | { validate: true } 54 | ) 55 | ); 56 | }, 57 | 58 | 'is case sensitive': function() { 59 | refute( 60 | this.model.set( 61 | { 62 | passwordRepeat: 'Password' 63 | }, 64 | { validate: true } 65 | ) 66 | ); 67 | }, 68 | 69 | 'setting both at the same time to the same value is valid': function() { 70 | assert( 71 | this.model.set( 72 | { 73 | password: 'a', 74 | passwordRepeat: 'a' 75 | }, 76 | { validate: true } 77 | ) 78 | ); 79 | } 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /test/validation/validators/length.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'length validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | postalCode: { 6 | length: 2 7 | } 8 | }; 9 | @withValidation 10 | class Model extends Backbone.Model { 11 | set(...args) { 12 | super.set(...args); 13 | return this.validationError === null; 14 | } 15 | } 16 | 17 | Model.validation = this.validation; 18 | this.model = new Model(); 19 | }, 20 | 21 | 'has default error message for string': function(done) { 22 | this.model.on('validated', function(model, error) { 23 | assert.equals({ postalCode: 'Postal code must be 2 characters' }, error); 24 | done(); 25 | }); 26 | this.model.set({ postalCode: '' }, { validate: true }); 27 | }, 28 | 29 | 'string with length shorter than length is invalid': function() { 30 | refute( 31 | this.model.set( 32 | { 33 | postalCode: 'a' 34 | }, 35 | { validate: true } 36 | ) 37 | ); 38 | }, 39 | 40 | 'string with length longer than length is invalid': function() { 41 | refute( 42 | this.model.set( 43 | { 44 | postalCode: 'aaa' 45 | }, 46 | { validate: true } 47 | ) 48 | ); 49 | }, 50 | 51 | 'string with length equal to length is valid': function() { 52 | assert( 53 | this.model.set( 54 | { 55 | postalCode: 'aa' 56 | }, 57 | { validate: true } 58 | ) 59 | ); 60 | }, 61 | 62 | 'spaces are treated as part of the string (no trimming)': function() { 63 | refute( 64 | this.model.set( 65 | { 66 | postalCode: 'aa ' 67 | }, 68 | { validate: true } 69 | ) 70 | ); 71 | }, 72 | 73 | 'non strings are treated as an error': function() { 74 | refute( 75 | this.model.set( 76 | { 77 | postalCode: 123 78 | }, 79 | { validate: true } 80 | ) 81 | ); 82 | }, 83 | 84 | 'when required is not specified': { 85 | 'undefined is invalid': function() { 86 | refute( 87 | this.model.set( 88 | { 89 | postalCode: undefined 90 | }, 91 | { validate: true } 92 | ) 93 | ); 94 | }, 95 | 96 | 'null is invalid': function() { 97 | refute( 98 | this.model.set( 99 | { 100 | postalCode: null 101 | }, 102 | { validate: true } 103 | ) 104 | ); 105 | } 106 | }, 107 | 108 | 'when required:false': { 109 | beforeEach: function() { 110 | this.validation.postalCode.required = false; 111 | }, 112 | 113 | 'null is valid': function() { 114 | assert( 115 | this.model.set( 116 | { 117 | postalCode: null 118 | }, 119 | { validate: true } 120 | ) 121 | ); 122 | }, 123 | 124 | 'undefined is valid': function() { 125 | assert( 126 | this.model.set( 127 | { 128 | postalCode: undefined 129 | }, 130 | { validate: true } 131 | ) 132 | ); 133 | } 134 | }, 135 | 136 | 'when required:true': { 137 | beforeEach: function() { 138 | this.validation.postalCode.required = true; 139 | }, 140 | 141 | 'undefined is invalid': function() { 142 | refute( 143 | this.model.set( 144 | { 145 | postalCode: undefined 146 | }, 147 | { validate: true } 148 | ) 149 | ); 150 | }, 151 | 152 | 'null is invalid': function() { 153 | refute( 154 | this.model.set( 155 | { 156 | postalCode: null 157 | }, 158 | { validate: true } 159 | ) 160 | ); 161 | } 162 | } 163 | } 164 | }; 165 | -------------------------------------------------------------------------------- /test/validation/validators/max.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'max validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | age: { 6 | max: 10 7 | } 8 | }; 9 | 10 | @withValidation 11 | class Model extends Backbone.Model { 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | Model.validation = this.validation; 18 | 19 | this.model = new Model(); 20 | }, 21 | 22 | 'has default error message': function(done) { 23 | this.model.on('validated', function(model, error) { 24 | assert.equals({ age: 'Age must be less than or equal to 10' }, error); 25 | done(); 26 | }); 27 | this.model.set({ age: 11 }, { validate: true }); 28 | }, 29 | 30 | 'number higher than max is invalid': function() { 31 | refute( 32 | this.model.set( 33 | { 34 | age: 11 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'non numeric value is invalid': function() { 42 | refute( 43 | this.model.set( 44 | { 45 | age: '10error' 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'number equal to max is valid': function() { 53 | assert( 54 | this.model.set( 55 | { 56 | age: 10 57 | }, 58 | { validate: true } 59 | ) 60 | ); 61 | }, 62 | 63 | 'number lower than max is valid': function() { 64 | assert( 65 | this.model.set( 66 | { 67 | age: 5 68 | }, 69 | { validate: true } 70 | ) 71 | ); 72 | }, 73 | 74 | 'numeric string values are treated as numbers': function() { 75 | assert( 76 | this.model.set( 77 | { 78 | age: '10' 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | }, 84 | 85 | 'when required is not specified': { 86 | 'undefined is invalid': function() { 87 | refute( 88 | this.model.set( 89 | { 90 | age: undefined 91 | }, 92 | { validate: true } 93 | ) 94 | ); 95 | }, 96 | 97 | 'null is invalid': function() { 98 | refute( 99 | this.model.set( 100 | { 101 | age: null 102 | }, 103 | { validate: true } 104 | ) 105 | ); 106 | } 107 | }, 108 | 109 | 'when required:false': { 110 | beforeEach: function() { 111 | this.validation.age.required = false; 112 | }, 113 | 114 | 'null is valid': function() { 115 | assert( 116 | this.model.set( 117 | { 118 | age: null 119 | }, 120 | { validate: true } 121 | ) 122 | ); 123 | }, 124 | 125 | 'undefined is valid': function() { 126 | assert( 127 | this.model.set( 128 | { 129 | age: undefined 130 | }, 131 | { validate: true } 132 | ) 133 | ); 134 | } 135 | }, 136 | 137 | 'when required:true': { 138 | beforeEach: function() { 139 | this.validation.age.required = true; 140 | }, 141 | 142 | 'undefined is invalid': function() { 143 | refute( 144 | this.model.set( 145 | { 146 | age: undefined 147 | }, 148 | { validate: true } 149 | ) 150 | ); 151 | }, 152 | 153 | 'null is invalid': function() { 154 | refute( 155 | this.model.set( 156 | { 157 | age: null 158 | }, 159 | { validate: true } 160 | ) 161 | ); 162 | } 163 | } 164 | } 165 | }; 166 | -------------------------------------------------------------------------------- /test/validation/validators/maxLength.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'maxLength validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | name: { 6 | maxLength: 2 7 | } 8 | }; 9 | @withValidation 10 | class Model extends Backbone.Model { 11 | set(...args) { 12 | super.set(...args); 13 | return this.validationError === null; 14 | } 15 | } 16 | Model.validation = this.validation; 17 | 18 | this.model = new Model(); 19 | }, 20 | 21 | 'has default error message for string': function(done) { 22 | this.model.on('validated', function(model, error) { 23 | assert.equals({ name: 'Name must be at most 2 characters' }, error); 24 | done(); 25 | }); 26 | this.model.set({ name: 'aaa' }, { validate: true }); 27 | }, 28 | 29 | 'string with length longer than maxLenght is invalid': function() { 30 | refute( 31 | this.model.set( 32 | { 33 | name: 'aaa' 34 | }, 35 | { validate: true } 36 | ) 37 | ); 38 | }, 39 | 40 | 'string with length equal to maxLength is valid': function() { 41 | assert( 42 | this.model.set( 43 | { 44 | name: 'aa' 45 | }, 46 | { validate: true } 47 | ) 48 | ); 49 | }, 50 | 51 | 'string with length shorter than maxLength is valid': function() { 52 | assert( 53 | this.model.set( 54 | { 55 | name: 'a' 56 | }, 57 | { validate: true } 58 | ) 59 | ); 60 | }, 61 | 62 | 'spaces are treated as part of the string (no trimming)': function() { 63 | refute( 64 | this.model.set( 65 | { 66 | name: 'a ' 67 | }, 68 | { validate: true } 69 | ) 70 | ); 71 | }, 72 | 73 | 'non strings are treated as an error': function() { 74 | refute( 75 | this.model.set( 76 | { 77 | name: 123 78 | }, 79 | { validate: true } 80 | ) 81 | ); 82 | }, 83 | 84 | 'when required is not specified': { 85 | 'undefined is invalid': function() { 86 | refute( 87 | this.model.set( 88 | { 89 | name: undefined 90 | }, 91 | { validate: true } 92 | ) 93 | ); 94 | }, 95 | 96 | 'null is invalid': function() { 97 | refute( 98 | this.model.set( 99 | { 100 | name: null 101 | }, 102 | { validate: true } 103 | ) 104 | ); 105 | } 106 | }, 107 | 108 | 'when required:false': { 109 | beforeEach: function() { 110 | this.validation.name.required = false; 111 | }, 112 | 113 | 'null is valid': function() { 114 | assert( 115 | this.model.set( 116 | { 117 | name: null 118 | }, 119 | { validate: true } 120 | ) 121 | ); 122 | }, 123 | 124 | 'undefined is valid': function() { 125 | assert( 126 | this.model.set( 127 | { 128 | name: undefined 129 | }, 130 | { validate: true } 131 | ) 132 | ); 133 | } 134 | }, 135 | 136 | 'when required:true': { 137 | beforeEach: function() { 138 | this.validation.name.required = true; 139 | }, 140 | 141 | 'undefined is invalid': function() { 142 | refute( 143 | this.model.set( 144 | { 145 | name: undefined 146 | }, 147 | { validate: true } 148 | ) 149 | ); 150 | }, 151 | 152 | 'null is invalid': function() { 153 | refute( 154 | this.model.set( 155 | { 156 | name: null 157 | }, 158 | { validate: true } 159 | ) 160 | ); 161 | } 162 | } 163 | } 164 | }; 165 | -------------------------------------------------------------------------------- /test/validation/validators/method.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'method validator': { 3 | beforeEach: function() { 4 | var that = this; 5 | 6 | @withValidation 7 | class Model extends Backbone.Model { 8 | static validation = { 9 | name: { 10 | fn: function(val, attr, computed) { 11 | that.ctx = this; 12 | that.attr = attr; 13 | that.computed = computed; 14 | if (name !== 'backbone') { 15 | return 'Error'; 16 | } 17 | } 18 | } 19 | }; 20 | 21 | set(...args) { 22 | super.set(...args); 23 | return this.validationError === null; 24 | } 25 | } 26 | 27 | this.model = new Model(); 28 | }, 29 | 30 | 'is invalid when method returns error message': function() { 31 | refute( 32 | this.model.set( 33 | { 34 | name: '' 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'is valid when method returns undefined': function() { 42 | refute( 43 | this.model.set( 44 | { 45 | name: 'backbone' 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'context is the model': function() { 53 | this.model.set( 54 | { 55 | name: '' 56 | }, 57 | { validate: true } 58 | ); 59 | assert.same(this.ctx, this.model); 60 | }, 61 | 62 | 'second argument is the name of the attribute being validated': function() { 63 | this.model.set({ name: '' }, { validate: true }); 64 | assert.equals('name', this.attr); 65 | }, 66 | 67 | 'third argument is a computed model state': function() { 68 | this.model.set({ attr: 'attr' }); 69 | this.model.set( 70 | { 71 | name: 'name', 72 | age: 1 73 | }, 74 | { validate: true } 75 | ); 76 | 77 | assert.equals({ attr: 'attr', name: 'name', age: 1 }, this.computed); 78 | } 79 | } 80 | }; 81 | 82 | module.exports = { 83 | 'method validator short hand syntax': { 84 | beforeEach: function() { 85 | var that = this; 86 | 87 | @withValidation 88 | class Model extends Backbone.Model { 89 | static validation = { 90 | name: function(val, attr, computed) { 91 | that.ctx = this; 92 | that.attr = attr; 93 | that.computed = computed; 94 | if (name !== 'backbone') { 95 | return 'Error'; 96 | } 97 | } 98 | }; 99 | 100 | set(...args) { 101 | super.set(...args); 102 | return this.validationError === null; 103 | } 104 | } 105 | 106 | this.model = new Model(); 107 | }, 108 | 109 | 'is invalid when method returns error message': function() { 110 | refute( 111 | this.model.set( 112 | { 113 | name: '' 114 | }, 115 | { validate: true } 116 | ) 117 | ); 118 | }, 119 | 120 | 'is valid when method returns undefined': function() { 121 | refute( 122 | this.model.set( 123 | { 124 | name: 'backbone' 125 | }, 126 | { validate: true } 127 | ) 128 | ); 129 | }, 130 | 131 | 'context is the model': function() { 132 | this.model.set( 133 | { 134 | name: '' 135 | }, 136 | { validate: true } 137 | ); 138 | assert.same(this.ctx, this.model); 139 | }, 140 | 141 | 'second argument is the name of the attribute being validated': function() { 142 | this.model.set({ name: '' }, { validate: true }); 143 | assert.equals('name', this.attr); 144 | }, 145 | 146 | 'third argument is a computed model state': function() { 147 | this.model.set({ attr: 'attr' }); 148 | this.model.set( 149 | { 150 | name: 'name', 151 | age: 1 152 | }, 153 | { validate: true } 154 | ); 155 | 156 | assert.equals({ attr: 'attr', name: 'name', age: 1 }, this.computed); 157 | } 158 | } 159 | }; 160 | 161 | module.exports = { 162 | 'method validator using other built in validator(s)': { 163 | beforeEach: function() { 164 | @withValidation 165 | class Model extends Backbone.Model { 166 | static validation = { 167 | name: function(val, attr, computed) { 168 | return Validation.validators.length(val, attr, 4, this); 169 | } 170 | }; 171 | 172 | set(...args) { 173 | super.set(...args); 174 | return this.validationError === null; 175 | } 176 | } 177 | 178 | Object.assign(Model.prototype, Validation.mixin); 179 | this.model = new Model(); 180 | }, 181 | 182 | 'it should format the error message returned from the built in validator': function() { 183 | assert.equals('Name must be 4 characters', this.model.preValidate('name', '')); 184 | } 185 | } 186 | }; 187 | -------------------------------------------------------------------------------- /test/validation/validators/min.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'min validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | age: { 6 | min: 1 7 | } 8 | }; 9 | 10 | @withValidation 11 | class Model extends Backbone.Model { 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | Model.validation = this.validation; 18 | 19 | this.model = new Model(); 20 | }, 21 | 22 | 'has default error message': function(done) { 23 | this.model.on('validated', function(model, error) { 24 | assert.equals({ age: 'Age must be greater than or equal to 1' }, error); 25 | done(); 26 | }); 27 | this.model.set({ age: 0 }, { validate: true }); 28 | }, 29 | 30 | 'number lower than min is invalid': function() { 31 | refute( 32 | this.model.set( 33 | { 34 | age: 0 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'non numeric value is invalid': function() { 42 | refute( 43 | this.model.set( 44 | { 45 | age: '10error' 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'number equal to min is valid': function() { 53 | assert( 54 | this.model.set( 55 | { 56 | age: 1 57 | }, 58 | { validate: true } 59 | ) 60 | ); 61 | }, 62 | 63 | 'number greater than min is valid': function() { 64 | assert( 65 | this.model.set( 66 | { 67 | age: 2 68 | }, 69 | { validate: true } 70 | ) 71 | ); 72 | }, 73 | 74 | 'numeric string values are treated as numbers': function() { 75 | assert( 76 | this.model.set( 77 | { 78 | age: '1' 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | }, 84 | 85 | 'when required is not specified': { 86 | 'undefined is invalid': function() { 87 | refute( 88 | this.model.set( 89 | { 90 | age: undefined 91 | }, 92 | { validate: true } 93 | ) 94 | ); 95 | }, 96 | 97 | 'null is invalid': function() { 98 | refute( 99 | this.model.set( 100 | { 101 | age: null 102 | }, 103 | { validate: true } 104 | ) 105 | ); 106 | } 107 | }, 108 | 109 | 'when required:false': { 110 | beforeEach: function() { 111 | this.validation.age.required = false; 112 | }, 113 | 114 | 'null is valid': function() { 115 | assert( 116 | this.model.set( 117 | { 118 | age: null 119 | }, 120 | { validate: true } 121 | ) 122 | ); 123 | }, 124 | 125 | 'undefined is valid': function() { 126 | assert( 127 | this.model.set( 128 | { 129 | age: undefined 130 | }, 131 | { validate: true } 132 | ) 133 | ); 134 | } 135 | }, 136 | 137 | 'when required:true': { 138 | beforeEach: function() { 139 | this.validation.age.required = true; 140 | }, 141 | 142 | 'undefined is invalid': function() { 143 | refute( 144 | this.model.set( 145 | { 146 | age: undefined 147 | }, 148 | { validate: true } 149 | ) 150 | ); 151 | }, 152 | 153 | 'null is invalid': function() { 154 | refute( 155 | this.model.set( 156 | { 157 | age: null 158 | }, 159 | { validate: true } 160 | ) 161 | ); 162 | } 163 | }, 164 | 165 | 'when min:0, 0 < val < 1': { 166 | setUp: function() { 167 | this.validation.aFloat = { 168 | min: 0 169 | }; 170 | }, 171 | "val is string, no leading zero, e.g. '.2'": function() { 172 | assert( 173 | this.model.set( 174 | { 175 | aFloat: '.2' 176 | }, 177 | { validate: true } 178 | ) 179 | ); 180 | }, 181 | "val is string, leading zero, e.g. '0.2'": function() { 182 | assert( 183 | this.model.set( 184 | { 185 | aFloat: '0.2' 186 | }, 187 | { validate: true } 188 | ) 189 | ); 190 | }, 191 | 'val is number, leading zero, e.g. 0.2': function() { 192 | assert( 193 | this.model.set( 194 | { 195 | aFloat: 0.2 196 | }, 197 | { validate: true } 198 | ) 199 | ); 200 | } 201 | } 202 | } 203 | }; 204 | -------------------------------------------------------------------------------- /test/validation/validators/minLength.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'minLength validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | name: { 6 | minLength: 2 7 | } 8 | }; 9 | 10 | @withValidation 11 | class Model extends Backbone.Model { 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | Model.validation = this.validation; 18 | 19 | this.model = new Model(); 20 | }, 21 | 22 | 'has default error message for string': function(done) { 23 | this.model.on('validated', function(model, error) { 24 | assert.equals({ name: 'Name must be at least 2 characters' }, error); 25 | done(); 26 | }); 27 | this.model.set({ name: '' }, { validate: true }); 28 | }, 29 | 30 | 'string with length shorter than minLenght is invalid': function() { 31 | refute( 32 | this.model.set( 33 | { 34 | name: 'a' 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'string with length equal to minLength is valid': function() { 42 | assert( 43 | this.model.set( 44 | { 45 | name: 'aa' 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'string with length greater than minLength is valid': function() { 53 | assert( 54 | this.model.set( 55 | { 56 | name: 'aaaa' 57 | }, 58 | { validate: true } 59 | ) 60 | ); 61 | }, 62 | 63 | 'spaces are treated as part of the string (no trimming)': function() { 64 | assert( 65 | this.model.set( 66 | { 67 | name: 'a ' 68 | }, 69 | { validate: true } 70 | ) 71 | ); 72 | }, 73 | 74 | 'non strings are treated as an error': function() { 75 | refute( 76 | this.model.set( 77 | { 78 | name: 123 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | }, 84 | 85 | 'when required is not specified': { 86 | 'undefined is invalid': function() { 87 | refute( 88 | this.model.set( 89 | { 90 | name: undefined 91 | }, 92 | { validate: true } 93 | ) 94 | ); 95 | }, 96 | 97 | 'null is invalid': function() { 98 | refute( 99 | this.model.set( 100 | { 101 | name: null 102 | }, 103 | { validate: true } 104 | ) 105 | ); 106 | } 107 | }, 108 | 109 | 'when required:false': { 110 | beforeEach: function() { 111 | this.validation.name.required = false; 112 | }, 113 | 114 | 'null is valid': function() { 115 | assert( 116 | this.model.set( 117 | { 118 | name: null 119 | }, 120 | { validate: true } 121 | ) 122 | ); 123 | }, 124 | 125 | 'undefined is valid': function() { 126 | assert( 127 | this.model.set( 128 | { 129 | name: undefined 130 | }, 131 | { validate: true } 132 | ) 133 | ); 134 | } 135 | }, 136 | 137 | 'when required:true': { 138 | beforeEach: function() { 139 | this.validation.name.required = true; 140 | }, 141 | 142 | 'undefined is invalid': function() { 143 | refute( 144 | this.model.set( 145 | { 146 | name: undefined 147 | }, 148 | { validate: true } 149 | ) 150 | ); 151 | }, 152 | 153 | 'null is invalid': function() { 154 | refute( 155 | this.model.set( 156 | { 157 | name: null 158 | }, 159 | { validate: true } 160 | ) 161 | ); 162 | } 163 | } 164 | } 165 | }; 166 | -------------------------------------------------------------------------------- /test/validation/validators/namedMethod.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'named method validator': { 3 | beforeEach: function() { 4 | @withValidation 5 | class Model extends Backbone.Model { 6 | static validation = { 7 | name: { 8 | fn: 'validateName' 9 | } 10 | }; 11 | 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | 18 | this.model = new Model(); 19 | }, 20 | 21 | 'is invalid when method returns error message': function() { 22 | refute(this.model.set({ name: '' }, { validate: true })); 23 | }, 24 | 25 | 'is valid when method returns undefined': function() { 26 | assert(this.model.set({ name: 'backbone' }, { validate: true })); 27 | }, 28 | 29 | 'context is the model': function() { 30 | this.model.set({ name: '' }, { validate: true }); 31 | assert.same(this.ctx, this.model); 32 | }, 33 | 34 | 'second argument is the name of the attribute being validated': function() { 35 | this.model.set({ name: '' }, { validate: true }); 36 | assert.equals('name', this.attr); 37 | }, 38 | 39 | 'third argument is a computed model state': function() { 40 | this.model.set({ attr: 'attr' }); 41 | this.model.set( 42 | { 43 | name: 'name', 44 | age: 1 45 | }, 46 | { validate: true } 47 | ); 48 | 49 | assert.equals({ attr: 'attr', name: 'name', age: 1 }, this.computed); 50 | } 51 | } 52 | }; 53 | 54 | module.exports = { 55 | 'named method validator short hand syntax': { 56 | beforeEach: function() { 57 | var that = this; 58 | 59 | @withValidation 60 | class Model extends Backbone.Model { 61 | static validation = { 62 | name: 'validateName' 63 | }; 64 | 65 | validateName(val, attr, computed) { 66 | that.ctx = this; 67 | that.attr = attr; 68 | that.computed = computed; 69 | if (val !== 'backbone') { 70 | return 'Error'; 71 | } 72 | } 73 | 74 | set(...args) { 75 | super.set(...args); 76 | return this.validationError === null; 77 | } 78 | } 79 | this.model = new Model(); 80 | }, 81 | 82 | 'is invalid when method returns error message': function() { 83 | refute(this.model.set({ name: '' }, { validate: true })); 84 | }, 85 | 86 | 'is valid when method returns undefined': function() { 87 | assert(this.model.set({ name: 'backbone' }, { validate: true })); 88 | }, 89 | 90 | 'context is the model': function() { 91 | this.model.set({ name: '' }, { validate: true }); 92 | assert.same(this.ctx, this.model); 93 | }, 94 | 95 | 'second argument is the name of the attribute being validated': function() { 96 | this.model.set({ name: '' }, { validate: true }); 97 | assert.equals('name', this.attr); 98 | }, 99 | 100 | 'third argument is a computed model state': function() { 101 | this.model.set({ attr: 'attr' }); 102 | this.model.set( 103 | { 104 | name: 'name', 105 | age: 1 106 | }, 107 | { validate: true } 108 | ); 109 | 110 | assert.equals({ attr: 'attr', name: 'name', age: 1 }, this.computed); 111 | } 112 | } 113 | }; 114 | -------------------------------------------------------------------------------- /test/validation/validators/oneOf.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'oneOf validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | country: { 6 | oneOf: ['Norway', 'Sweeden'] 7 | } 8 | }; 9 | 10 | @withValidation 11 | class Model extends Backbone.Model { 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | Model.validation = this.validation; 18 | 19 | this.model = new Model(); 20 | }, 21 | 22 | 'has default error message': function(done) { 23 | this.model.on('validated', function(model, error) { 24 | assert.equals({ country: 'Country must be one of: Norway, Sweeden' }, error); 25 | done(); 26 | }); 27 | this.model.set({ country: '' }, { validate: true }); 28 | }, 29 | 30 | 'value is one of the values in the array is valid': function() { 31 | assert( 32 | this.model.set( 33 | { 34 | country: 'Norway' 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'value is not one of the values in the arraye is invalid': function() { 42 | refute( 43 | this.model.set( 44 | { 45 | country: 'Denmark' 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'is case sensitive': function() { 53 | refute( 54 | this.model.set( 55 | { 56 | country: 'sweeden' 57 | }, 58 | { validate: true } 59 | ) 60 | ); 61 | }, 62 | 63 | 'when required is not specified': { 64 | 'undefined is invalid': function() { 65 | refute( 66 | this.model.set( 67 | { 68 | country: undefined 69 | }, 70 | { validate: true } 71 | ) 72 | ); 73 | }, 74 | 75 | 'null is invalid': function() { 76 | refute( 77 | this.model.set( 78 | { 79 | country: null 80 | }, 81 | { validate: true } 82 | ) 83 | ); 84 | } 85 | }, 86 | 87 | 'when required:false': { 88 | beforeEach: function() { 89 | this.validation.country.required = false; 90 | }, 91 | 92 | 'null is valid': function() { 93 | assert( 94 | this.model.set( 95 | { 96 | country: null 97 | }, 98 | { validate: true } 99 | ) 100 | ); 101 | }, 102 | 103 | 'undefined is valid': function() { 104 | assert( 105 | this.model.set( 106 | { 107 | country: undefined 108 | }, 109 | { validate: true } 110 | ) 111 | ); 112 | } 113 | }, 114 | 115 | 'when required:true': { 116 | beforeEach: function() { 117 | this.validation.country.required = true; 118 | }, 119 | 120 | 'undefined is invalid': function() { 121 | refute( 122 | this.model.set( 123 | { 124 | country: undefined 125 | }, 126 | { validate: true } 127 | ) 128 | ); 129 | }, 130 | 131 | 'null is invalid': function() { 132 | refute( 133 | this.model.set( 134 | { 135 | country: null 136 | }, 137 | { validate: true } 138 | ) 139 | ); 140 | } 141 | } 142 | } 143 | }; 144 | -------------------------------------------------------------------------------- /test/validation/validators/pattern.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'pattern validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | name: { 6 | pattern: /^test/ 7 | }, 8 | email: { 9 | pattern: 'email' 10 | } 11 | }; 12 | 13 | @withValidation 14 | class Model extends Backbone.Model { 15 | set(...args) { 16 | super.set(...args); 17 | return this.validationError === null; 18 | } 19 | } 20 | Model.validation = this.validation; 21 | 22 | this.model = new Model({ 23 | name: 'test', 24 | email: 'test@example.com' 25 | }); 26 | }, 27 | 28 | 'has default error message': function(done) { 29 | this.model.on('validated', function(model, error) { 30 | assert.equals({ email: 'Email must be a valid email' }, error); 31 | done(); 32 | }); 33 | this.model.set({ email: '' }, { validate: true }); 34 | }, 35 | 36 | 'has default error message for inline pattern': function(done) { 37 | this.model.on('validated', function(model, error) { 38 | assert.equals({ name: 'Name is invalid' }, error); 39 | done(); 40 | }); 41 | this.model.set({ name: '' }, { validate: true }); 42 | }, 43 | 44 | 'value not matching pattern is invalid': function() { 45 | refute( 46 | this.model.set( 47 | { 48 | name: 'aaa' 49 | }, 50 | { validate: true } 51 | ) 52 | ); 53 | }, 54 | 55 | 'value matching pattern is valid': function() { 56 | assert( 57 | this.model.set( 58 | { 59 | name: 'test' 60 | }, 61 | { validate: true } 62 | ) 63 | ); 64 | }, 65 | 66 | 'when required is not specified': { 67 | 'undefined is invalid': function() { 68 | refute( 69 | this.model.set( 70 | { 71 | name: undefined 72 | }, 73 | { validate: true } 74 | ) 75 | ); 76 | }, 77 | 78 | 'null is invalid': function() { 79 | refute( 80 | this.model.set( 81 | { 82 | name: null 83 | }, 84 | { validate: true } 85 | ) 86 | ); 87 | } 88 | }, 89 | 90 | 'when required:false': { 91 | beforeEach: function() { 92 | this.validation.name.required = false; 93 | }, 94 | 95 | 'null is valid': function() { 96 | assert( 97 | this.model.set( 98 | { 99 | name: null 100 | }, 101 | { validate: true } 102 | ) 103 | ); 104 | }, 105 | 106 | 'undefined is valid': function() { 107 | assert( 108 | this.model.set( 109 | { 110 | name: undefined 111 | }, 112 | { validate: true } 113 | ) 114 | ); 115 | } 116 | }, 117 | 118 | 'when required:true': { 119 | beforeEach: function() { 120 | this.validation.name.required = true; 121 | }, 122 | 123 | 'undefined is invalid': function() { 124 | refute( 125 | this.model.set( 126 | { 127 | name: undefined 128 | }, 129 | { validate: true } 130 | ) 131 | ); 132 | }, 133 | 134 | 'null is invalid': function() { 135 | refute( 136 | this.model.set( 137 | { 138 | name: null 139 | }, 140 | { validate: true } 141 | ) 142 | ); 143 | } 144 | }, 145 | 146 | 'can use one of the built-in patterns by specifying the name of it': function() { 147 | refute( 148 | this.model.set( 149 | { 150 | email: 'aaa' 151 | }, 152 | { validate: true } 153 | ) 154 | ); 155 | 156 | assert( 157 | this.model.set( 158 | { 159 | email: 'a@example.com' 160 | }, 161 | { validate: true } 162 | ) 163 | ); 164 | } 165 | } 166 | }; 167 | -------------------------------------------------------------------------------- /test/validation/validators/patterns.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Backbone.Validation patterns': { 3 | beforeEach: function() { 4 | var that = this; 5 | this.valid = function(value) { 6 | assert(value.match(that.pattern), value + ' should be valid'); 7 | }; 8 | 9 | this.invalid = function(value) { 10 | refute(value.match(that.pattern), value + ' should be invalid'); 11 | }; 12 | }, 13 | 14 | 'email pattern matches all valid email addresses': function() { 15 | this.pattern = Validation.patterns.email; 16 | 17 | this.valid('name@example.com'); 18 | this.valid('name@example.com'); 19 | this.valid('name+@example.co'); 20 | this.valid('n@e.co'); 21 | this.valid('first.last@backbone.example.com'); 22 | this.valid('(unsual)[very]@strange.example.com'); 23 | this.valid('x@example.com'); 24 | 25 | this.invalid('name'); 26 | this.invalid('name@'); 27 | this.invalid('name@example'); 28 | this.invalid('name.@example.c'); 29 | this.invalid('name,@example.c'); 30 | this.invalid('name;@example.c'); 31 | this.invalid('name@example.com.'); 32 | this.invalid('Abc.example.com'); 33 | this.invalid('a"b(c)d,e:f;gi[jk]l@example.com'); 34 | this.invalid('just"not"right@example.com'); 35 | }, 36 | 37 | 'email pattern is case insensitive': function() { 38 | this.pattern = Validation.patterns.email; 39 | 40 | this.valid('NaMe@example.COM'); 41 | this.valid('NAME@EXAMPLE.COM'); 42 | }, 43 | 44 | 'url pattern matches all valid urls': function() { 45 | this.pattern = Validation.patterns.url; 46 | 47 | this.valid('http://thedersen.com'); 48 | this.valid('http://www.thedersen.com/'); 49 | this.valid('http://øya.no/'); 50 | this.valid('http://öya.no/'); 51 | this.valid('https://thedersen.com/'); 52 | this.valid('http://thedersen.com/backbone.validation/?query=string'); 53 | this.valid('ftp://thedersen.com'); 54 | this.valid('http://127.0.0.1'); 55 | 56 | this.invalid('thedersen.com'); 57 | this.invalid('http://thedersen'); 58 | this.invalid('http://thedersen.'); 59 | this.invalid('http://thedersen,com'); 60 | this.invalid('http://thedersen;com'); 61 | this.invalid('http://.thedersen.com'); 62 | this.invalid('http://127.0.0.1.'); 63 | }, 64 | 65 | 'url pattern is case insensitive': function() { 66 | this.pattern = Validation.patterns.url; 67 | 68 | this.valid('http://Thedersen.com'); 69 | this.valid('HTTP://THEDERSEN.COM'); 70 | }, 71 | 72 | 'number pattern matches all numbers, including decimal numbers': function() { 73 | this.pattern = Validation.patterns.number; 74 | 75 | this.valid('123'); 76 | this.valid('-123'); 77 | this.valid('123,000'); 78 | this.valid('-123,000'); 79 | this.valid('123.45'); 80 | this.valid('-123.45'); 81 | this.valid('123,000.45'); 82 | this.valid('-123,000.45'); 83 | this.valid('123,000.00'); 84 | this.valid('-123,000.00'); 85 | this.valid('.10'); 86 | this.valid('123.'); 87 | 88 | this.invalid('abc'); 89 | this.invalid('abc123'); 90 | this.invalid('123abc'); 91 | this.invalid('123.000,00'); 92 | this.invalid('123.0.0,00'); 93 | }, 94 | 95 | 'digits pattern matches single or multiple digits': function() { 96 | this.pattern = Validation.patterns.digits; 97 | 98 | this.valid('1'); 99 | this.valid('123'); 100 | 101 | this.invalid('a'); 102 | this.invalid('a123'); 103 | this.invalid('123a'); 104 | } 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /test/validation/validators/range.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'range validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | age: { 6 | range: [1, 10] 7 | } 8 | }; 9 | 10 | @withValidation 11 | class Model extends Backbone.Model { 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | Model.validation = this.validation; 18 | 19 | this.model = new Model(); 20 | }, 21 | 22 | 'has default error message': function(done) { 23 | this.model.on('validated', function(model, error) { 24 | assert.equals({ age: 'Age must be between 1 and 10' }, error); 25 | done(); 26 | }); 27 | this.model.set({ age: 0 }, { validate: true }); 28 | }, 29 | 30 | 'number lower than first value is invalid': function() { 31 | refute( 32 | this.model.set( 33 | { 34 | age: 0 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'number equal to first value is valid': function() { 42 | assert( 43 | this.model.set( 44 | { 45 | age: 1 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'number higher than last value is invalid': function() { 53 | refute( 54 | this.model.set( 55 | { 56 | age: 11 57 | }, 58 | { validate: true } 59 | ) 60 | ); 61 | }, 62 | 63 | 'number equal to last value is valid': function() { 64 | assert( 65 | this.model.set( 66 | { 67 | age: 10 68 | }, 69 | { validate: true } 70 | ) 71 | ); 72 | }, 73 | 74 | 'number in range is valid': function() { 75 | assert( 76 | this.model.set( 77 | { 78 | age: 5 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | }, 84 | 85 | 'when required is not specified': { 86 | 'undefined is invalid': function() { 87 | refute( 88 | this.model.set( 89 | { 90 | age: undefined 91 | }, 92 | { validate: true } 93 | ) 94 | ); 95 | }, 96 | 97 | 'null is invalid': function() { 98 | refute( 99 | this.model.set( 100 | { 101 | age: null 102 | }, 103 | { validate: true } 104 | ) 105 | ); 106 | } 107 | }, 108 | 109 | 'when required:false': { 110 | beforeEach: function() { 111 | this.validation.age.required = false; 112 | }, 113 | 114 | 'null is valid': function() { 115 | assert( 116 | this.model.set( 117 | { 118 | age: null 119 | }, 120 | { validate: true } 121 | ) 122 | ); 123 | }, 124 | 125 | 'undefined is valid': function() { 126 | assert( 127 | this.model.set( 128 | { 129 | age: undefined 130 | }, 131 | { validate: true } 132 | ) 133 | ); 134 | } 135 | }, 136 | 137 | 'when required:true': { 138 | beforeEach: function() { 139 | this.validation.age.required = true; 140 | }, 141 | 142 | 'undefined is invalid': function() { 143 | refute( 144 | this.model.set( 145 | { 146 | age: undefined 147 | }, 148 | { validate: true } 149 | ) 150 | ); 151 | }, 152 | 153 | 'null is invalid': function() { 154 | refute( 155 | this.model.set( 156 | { 157 | age: null 158 | }, 159 | { validate: true } 160 | ) 161 | ); 162 | } 163 | } 164 | } 165 | }; 166 | -------------------------------------------------------------------------------- /test/validation/validators/rangeLength.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'rangeLength validator': { 3 | beforeEach: function() { 4 | this.validation = { 5 | name: { 6 | rangeLength: [2, 4] 7 | } 8 | }; 9 | 10 | @withValidation 11 | class Model extends Backbone.Model { 12 | set(...args) { 13 | super.set(...args); 14 | return this.validationError === null; 15 | } 16 | } 17 | Model.validation = this.validation; 18 | 19 | this.model = new Model(); 20 | }, 21 | 22 | 'has default error message for strings': function(done) { 23 | this.model.on('validated', function(model, error) { 24 | assert.equals({ name: 'Name must be between 2 and 4 characters' }, error); 25 | done(); 26 | }); 27 | this.model.set({ name: 'a' }, { validate: true }); 28 | }, 29 | 30 | 'string with length shorter than first value is invalid': function() { 31 | refute( 32 | this.model.set( 33 | { 34 | name: 'a' 35 | }, 36 | { validate: true } 37 | ) 38 | ); 39 | }, 40 | 41 | 'string with length equal to first value is valid': function() { 42 | assert( 43 | this.model.set( 44 | { 45 | name: 'aa' 46 | }, 47 | { validate: true } 48 | ) 49 | ); 50 | }, 51 | 52 | 'string with length longer than last value is invalid': function() { 53 | refute( 54 | this.model.set( 55 | { 56 | name: 'aaaaa' 57 | }, 58 | { validate: true } 59 | ) 60 | ); 61 | }, 62 | 63 | 'string with length equal to last value is valid': function() { 64 | assert( 65 | this.model.set( 66 | { 67 | name: 'aaaa' 68 | }, 69 | { validate: true } 70 | ) 71 | ); 72 | }, 73 | 74 | 'string with length within range is valid': function() { 75 | assert( 76 | this.model.set( 77 | { 78 | name: 'aaa' 79 | }, 80 | { validate: true } 81 | ) 82 | ); 83 | }, 84 | 85 | 'spaces are treated as part of the string (no trimming)': function() { 86 | refute( 87 | this.model.set( 88 | { 89 | name: 'aaaa ' 90 | }, 91 | { validate: true } 92 | ) 93 | ); 94 | }, 95 | 96 | 'non strings are treated as an error': function() { 97 | refute( 98 | this.model.set( 99 | { 100 | name: 123 101 | }, 102 | { validate: true } 103 | ) 104 | ); 105 | }, 106 | 107 | 'when required is not specified': { 108 | 'undefined is invalid': function() { 109 | refute( 110 | this.model.set( 111 | { 112 | name: undefined 113 | }, 114 | { validate: true } 115 | ) 116 | ); 117 | }, 118 | 119 | 'null is invalid': function() { 120 | refute( 121 | this.model.set( 122 | { 123 | name: null 124 | }, 125 | { validate: true } 126 | ) 127 | ); 128 | } 129 | }, 130 | 131 | 'when required:false': { 132 | beforeEach: function() { 133 | this.validation.name.required = false; 134 | }, 135 | 136 | 'null is valid': function() { 137 | assert( 138 | this.model.set( 139 | { 140 | name: null 141 | }, 142 | { validate: true } 143 | ) 144 | ); 145 | }, 146 | 147 | 'undefined is valid': function() { 148 | assert( 149 | this.model.set( 150 | { 151 | name: undefined 152 | }, 153 | { validate: true } 154 | ) 155 | ); 156 | } 157 | }, 158 | 159 | 'when required:true': { 160 | beforeEach: function() { 161 | this.validation.name.required = true; 162 | }, 163 | 164 | 'undefined is invalid': function() { 165 | refute( 166 | this.model.set( 167 | { 168 | name: undefined 169 | }, 170 | { validate: true } 171 | ) 172 | ); 173 | }, 174 | 175 | 'null is invalid': function() { 176 | refute( 177 | this.model.set( 178 | { 179 | name: null 180 | }, 181 | { validate: true } 182 | ) 183 | ); 184 | } 185 | } 186 | } 187 | }; 188 | -------------------------------------------------------------------------------- /test/validation/validators/required.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'required validator': { 3 | beforeEach: function() { 4 | var that = this; 5 | 6 | @withValidation 7 | class Model extends Backbone.Model { 8 | static validation = { 9 | name: { 10 | required: true 11 | }, 12 | agree: { 13 | required: true 14 | }, 15 | posts: { 16 | required: true 17 | }, 18 | dependsOnName: { 19 | required: function(val, attr, computed) { 20 | that.ctx = this; 21 | that.attr = attr; 22 | that.computed = computed; 23 | return this.get('name') === 'name'; 24 | } 25 | } 26 | }; 27 | 28 | set(...args) { 29 | super.set(...args); 30 | return this.validationError === null; 31 | } 32 | } 33 | 34 | this.model = new Model({ 35 | name: 'name', 36 | agree: true, 37 | posts: ['post'], 38 | dependsOnName: 'depends' 39 | }); 40 | }, 41 | 42 | 'has default error message': function(done) { 43 | this.model.on('validated', function(model, error) { 44 | assert.equals({ name: 'Name is required' }, error); 45 | done(); 46 | }); 47 | this.model.set({ name: '' }, { validate: true }); 48 | }, 49 | 50 | 'empty string is invalid': function() { 51 | refute( 52 | this.model.set( 53 | { 54 | name: '' 55 | }, 56 | { validate: true } 57 | ) 58 | ); 59 | }, 60 | 61 | 'non-empty string is valid': function() { 62 | assert( 63 | this.model.set( 64 | { 65 | name: 'a' 66 | }, 67 | { validate: true } 68 | ) 69 | ); 70 | }, 71 | 72 | 'string with just spaces is invalid': function() { 73 | refute( 74 | this.model.set( 75 | { 76 | name: ' ' 77 | }, 78 | { validate: true } 79 | ) 80 | ); 81 | }, 82 | 83 | 'null is invalid': function() { 84 | refute( 85 | this.model.set( 86 | { 87 | name: null 88 | }, 89 | { validate: true } 90 | ) 91 | ); 92 | }, 93 | 94 | 'undefined is invalid': function() { 95 | refute( 96 | this.model.set( 97 | { 98 | name: undefined 99 | }, 100 | { validate: true } 101 | ) 102 | ); 103 | }, 104 | 105 | 'false boolean is valid': function() { 106 | assert( 107 | this.model.set( 108 | { 109 | agree: false 110 | }, 111 | { validate: true } 112 | ) 113 | ); 114 | }, 115 | 116 | 'true boolean is valid': function() { 117 | assert( 118 | this.model.set( 119 | { 120 | agree: true 121 | }, 122 | { validate: true } 123 | ) 124 | ); 125 | }, 126 | 127 | 'empty array is invalid': function() { 128 | refute( 129 | this.model.set( 130 | { 131 | posts: [] 132 | }, 133 | { validate: true } 134 | ) 135 | ); 136 | }, 137 | 138 | 'non-empty array is valid': function() { 139 | assert( 140 | this.model.set( 141 | { 142 | posts: ['post'] 143 | }, 144 | { validate: true } 145 | ) 146 | ); 147 | }, 148 | 149 | 'required can be specified as a method returning true or false': function() { 150 | this.model.set({ name: 'aaa' }, { validate: true }); 151 | 152 | assert( 153 | this.model.set( 154 | { 155 | dependsOnName: undefined 156 | }, 157 | { validate: true } 158 | ) 159 | ); 160 | 161 | this.model.set({ name: 'name' }, { validate: true }); 162 | 163 | refute( 164 | this.model.set( 165 | { 166 | dependsOnName: undefined 167 | }, 168 | { validate: true } 169 | ) 170 | ); 171 | }, 172 | 173 | 'context is the model': function() { 174 | this.model.set( 175 | { 176 | dependsOnName: '' 177 | }, 178 | { validate: true } 179 | ); 180 | assert.same(this.ctx, this.model); 181 | }, 182 | 183 | 'second argument is the name of the attribute being validated': function() { 184 | this.model.set({ dependsOnName: '' }, { validate: true }); 185 | assert.equals('dependsOnName', this.attr); 186 | }, 187 | 188 | 'third argument is a computed model state': function() { 189 | this.model.set({ attr: 'attr' }); 190 | this.model.set( 191 | { 192 | name: 'name', 193 | posts: ['post'], 194 | dependsOnName: 'value' 195 | }, 196 | { validate: true } 197 | ); 198 | 199 | assert.equals( 200 | { 201 | agree: true, 202 | attr: 'attr', 203 | dependsOnName: 'value', 204 | name: 'name', 205 | posts: ['post'] 206 | }, 207 | this.computed 208 | ); 209 | } 210 | } 211 | }; 212 | -------------------------------------------------------------------------------- /test/virtualcollection/virtualstate.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import * as _ from 'lodash-es'; 3 | import { fixture, defineCE } from '@open-wc/testing-helpers'; 4 | import { LitElement } from 'lit-element'; 5 | import { Collection } from '../../nextbone'; 6 | import { virtualState, VirtualCollection } from '../../virtualcollection'; 7 | 8 | import { expect } from 'chai'; 9 | 10 | describe('virtualState', () => { 11 | it('should preserve decorated class name', async () => { 12 | class Test extends LitElement { 13 | @virtualState 14 | virtualProp; 15 | } 16 | 17 | expect(Test.name).to.equal('Test'); 18 | }); 19 | 20 | it('should call createProperty', async () => { 21 | const createPropSpy = sinon.spy(); 22 | 23 | class Test extends LitElement { 24 | static createProperty() { 25 | createPropSpy(); 26 | } 27 | @virtualState 28 | virtualProp; 29 | } 30 | 31 | const tag = defineCE(Test); 32 | const el = await fixture(`<${tag}>`); 33 | sinon.assert.calledOnce(createPropSpy); 34 | }); 35 | 36 | it('should set the assigned value to the parent of the VirtualCollection property', async () => { 37 | const vc = new VirtualCollection(); 38 | class Test extends LitElement { 39 | @virtualState 40 | virtualProp = vc; 41 | } 42 | 43 | const tag = defineCE(Test); 44 | const el = await fixture(`<${tag}>`); 45 | 46 | const collection = new Collection(); 47 | el.virtualProp = collection; 48 | expect(el.virtualProp).to.eql(vc); 49 | expect(el.virtualProp.parent).to.eql(collection); 50 | }); 51 | 52 | it('should call requestUpdate when content changes', async () => { 53 | class Test extends LitElement { 54 | @virtualState 55 | virtualProp = new VirtualCollection(null, { 56 | filter: { foo: 'bar' } 57 | }); 58 | } 59 | 60 | const tag = defineCE(Test); 61 | const el = await fixture(`<${tag}>`); 62 | 63 | const collection = new Collection(); 64 | el.virtualProp = collection; 65 | const requestSpy = sinon.spy(el, 'requestUpdate'); 66 | 67 | collection.add({ foo: 'baz' }); 68 | sinon.assert.notCalled(requestSpy); 69 | collection.add({ foo: 'bar' }); 70 | sinon.assert.calledOnce(requestSpy); 71 | }); 72 | 73 | it('should accept a filter option', async () => { 74 | const filterSpy = sinon.spy(); 75 | class Test extends LitElement { 76 | @virtualState({ 77 | filter: function(model, index) { 78 | filterSpy.call(this, model, index); 79 | } 80 | }) 81 | virtualProp = new VirtualCollection(); 82 | } 83 | 84 | const tag = defineCE(Test); 85 | const el = await fixture(`<${tag}>`); 86 | 87 | const collection = new Collection(); 88 | el.virtualProp = collection; 89 | collection.add({ foo: 'baz' }); 90 | sinon.assert.calledOnce(filterSpy); 91 | sinon.assert.calledOn(filterSpy, el); 92 | }); 93 | 94 | it('should create a virtual collection for unitialized property', async () => { 95 | class Test extends LitElement { 96 | @virtualState 97 | virtualProp; 98 | } 99 | 100 | const tag = defineCE(Test); 101 | const el = await fixture(`<${tag}>`); 102 | 103 | const collection = new Collection(); 104 | el.virtualProp = collection; 105 | expect(el.virtualProp).to.be.instanceOf(VirtualCollection); 106 | expect(el.virtualProp.parent).to.eql(collection); 107 | }); 108 | 109 | it('should stop listening to parent when disconnected', async () => { 110 | class Test extends LitElement { 111 | @virtualState 112 | virtualProp; 113 | } 114 | 115 | const tag = defineCE(Test); 116 | const el = await fixture(`<${tag}>`); 117 | 118 | const collection = new Collection(); 119 | el.virtualProp = collection; 120 | const requestSpy = sinon.spy(el, 'requestUpdate'); 121 | 122 | el.remove(); 123 | 124 | collection.add({ foo: 'baz' }); 125 | sinon.assert.notCalled(requestSpy); 126 | expect(el.virtualProp.length).to.equal(0); 127 | }); 128 | 129 | it('should listen to parent changes when disconnected and reconnected', async () => { 130 | class Test extends LitElement { 131 | @virtualState 132 | virtualProp; 133 | } 134 | 135 | const tag = defineCE(Test); 136 | const el = await fixture(`<${tag}>`); 137 | 138 | const collection = new Collection(); 139 | el.virtualProp = collection; 140 | 141 | const parentEl = el.parentElement; 142 | el.remove(); 143 | parentEl.appendChild(el); 144 | 145 | const requestSpy = sinon.spy(el, 'requestUpdate'); 146 | 147 | collection.add({ foo: 'baz' }); 148 | sinon.assert.calledOnce(requestSpy); 149 | expect(el.virtualProp.length).to.equal(1); 150 | }); 151 | 152 | it('should accept parent property', async () => { 153 | class Test extends LitElement { 154 | @virtualState({ 155 | parent: 'parentProp' 156 | }) 157 | virtualProp; 158 | } 159 | 160 | const tag = defineCE(Test); 161 | const el = await fixture(`<${tag}>`); 162 | 163 | const collection = new Collection(); 164 | el.parentProp = collection; 165 | expect(el.parentProp).to.eql(collection); 166 | expect(el.virtualProp.parent).to.eql(collection); 167 | }); 168 | 169 | it('should allow to share same parent property', async () => { 170 | class Test extends LitElement { 171 | @virtualState({ 172 | parent: 'parentProp' 173 | }) 174 | virtualProp; 175 | 176 | @virtualState({ 177 | parent: 'parentProp' 178 | }) 179 | otherVirtualProp; 180 | } 181 | 182 | const tag = defineCE(Test); 183 | const el = await fixture(`<${tag}>`); 184 | 185 | const collection = new Collection(); 186 | el.parentProp = collection; 187 | expect(el.parentProp).to.eql(collection); 188 | expect(el.virtualProp.parent).to.eql(collection); 189 | expect(el.otherVirtualProp.parent).to.eql(collection); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /tools/migrate-docs.cjs: -------------------------------------------------------------------------------- 1 | const TurnDown = require('turndown'); 2 | const fs = require('fs'); 3 | 4 | const htmlContent = fs.readFileSync('./tools/backbonejs.html', { encoding: 'utf-8' }); 5 | const turnDown = new TurnDown({ codeBlockStyle: 'fenced', headingStyle: 'atx' }); 6 | 7 | turnDown.remove(node => { 8 | return node.matches('#sidebar'); 9 | }); 10 | 11 | turnDown.remove(['style', 'table']); 12 | 13 | turnDown.addRule('precode', { 14 | filter: ['pre'], 15 | replacement: function(content, node) { 16 | return '```js\n' + node.textContent.trim() + '\n```'; 17 | } 18 | }); 19 | 20 | turnDown.addRule('bheader', { 21 | filter: node => { 22 | return node.matches('b.header'); 23 | }, 24 | replacement: function(content) { 25 | return '### ' + content + '\n'; 26 | } 27 | }); 28 | 29 | const markDown = turnDown.turndown(htmlContent); 30 | 31 | fs.writeFileSync('./tools/backbonejs.md', markDown); 32 | -------------------------------------------------------------------------------- /tools/qunit-to-wtr.js: -------------------------------------------------------------------------------- 1 | // quick and dirty script 2 | 3 | import { readFileSync, writeFileSync } from 'node:fs'; 4 | 5 | const files = ['model.js']; 6 | 7 | const testRegex = /QUnit.test\(/; 8 | const moduleRegex = /QUnit.module\('(.*)'/; 9 | 10 | const header = ` 11 | import * as Backbone from 'nextbone' 12 | import * as _ from 'lodash-es' 13 | import {assert} from '@esm-bundle/chai' 14 | 15 | 16 | `; 17 | 18 | function transformFile(file) { 19 | const lines = readFileSync(`test/core/${file}`) 20 | .toString() 21 | .split('\r\n'); 22 | 23 | for (let i = 0; i < lines.length; i++) { 24 | const line = lines[i]; 25 | const moduleMatch = line.match(moduleRegex); 26 | if (moduleMatch) { 27 | lines[0] = `describe('${moduleMatch[1]}', function() {`; 28 | 29 | break; 30 | } 31 | } 32 | 33 | lines[lines.length - 2] = '})'; 34 | 35 | for (let i = 0; i < lines.length; i++) { 36 | const line = lines[i]; 37 | const testMatch = line.match(testRegex); 38 | if (testMatch) { 39 | let asyncIndex = i + 1; 40 | let isAsync = lines[asyncIndex].includes('assert.async'); 41 | if (!isAsync) { 42 | asyncIndex++; 43 | isAsync = lines[asyncIndex].includes('assert.async'); 44 | } 45 | 46 | let newLine = line.replace('QUnit.test', 'it'); 47 | newLine = newLine.replace('function(assert)', `function(${isAsync ? 'done' : ''})`); 48 | 49 | lines[i] = newLine; 50 | if (isAsync) { 51 | lines[asyncIndex] = ''; 52 | } 53 | 54 | continue; 55 | } 56 | 57 | if (line.includes('assert.expect')) { 58 | lines[i] = ''; 59 | 60 | continue; 61 | } 62 | 63 | if (line.includes('QUnit.skip')) { 64 | lines[i] = line.replace('QUnit.skip', 'it.skip'); 65 | 66 | continue; 67 | } 68 | 69 | if (line.includes('assert.ok')) { 70 | lines[i] = line.replace('assert.ok', 'assert.isOk'); 71 | 72 | continue; 73 | } 74 | 75 | if (line.includes('assert.raises')) { 76 | lines[i] = line.replace('assert.raises', 'assert.throws'); 77 | 78 | continue; 79 | } 80 | } 81 | 82 | writeFileSync('test/new-core/' + file, header + lines.join('\r\n')); 83 | } 84 | 85 | files.forEach(transformFile); 86 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | // Change this to match your project 3 | "include": [ 4 | "class-utils.js", 5 | "computed.js", 6 | "dom-utils.js", 7 | "form.js", 8 | "localstorage.js", 9 | "validation.js", 10 | "virtualcollection.js" 11 | ], 12 | "compilerOptions": { 13 | "target": "ES2017", 14 | "lib": [ 15 | "es2020", 16 | "DOM" 17 | ], 18 | "module": "NodeNext", 19 | "outDir": "types", 20 | // Tells TypeScript to read JS files, as 21 | // normally they are ignored as source files 22 | "allowJs": true, 23 | // Generate d.ts files 24 | "declaration": true, 25 | // This compiler run should 26 | // only output d.ts files 27 | "emitDeclarationOnly": true, 28 | // go to js file when using IDE functions like 29 | // "Go to Definition" in VSCode 30 | "declarationMap": true 31 | } 32 | } -------------------------------------------------------------------------------- /types/class-utils.d.ts: -------------------------------------------------------------------------------- 1 | export function asyncMethod(protoOrDescriptor: any, methodName: any, propertyDescriptor: any): any; 2 | export function defineAsyncMethods(klass: any, methodNames: any): void; 3 | //# sourceMappingURL=class-utils.d.ts.map -------------------------------------------------------------------------------- /types/class-utils.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"class-utils.d.ts","sourceRoot":"","sources":["../class-utils.js"],"names":[],"mappings":"AA0BO,mGAON;AAEM,uEAON"} -------------------------------------------------------------------------------- /types/computed.d.ts: -------------------------------------------------------------------------------- 1 | export type ComputedStaticMixin = { 2 | computed: ComputedDefs; 3 | }; 4 | export type ComputedFieldGet = (fields: Record) => any; 5 | export type ComputedField = { 6 | depends: string[]; 7 | get: ComputedFieldGet; 8 | /** 9 | * Not possible to use rest arams here: https://github.com/Microsoft/TypeScript/issues/15190 10 | */ 11 | set: (value: any, fields: Record) => any; 12 | }; 13 | export type ShortHandComputedField1 = [string, ComputedFieldGet]; 14 | export type ShortHandComputedField2 = [string, string, ComputedFieldGet]; 15 | export type ShortHandComputedField3 = [string, string, string, ComputedFieldGet]; 16 | export type ShortHandComputedField4 = [string, string, string, string, ComputedFieldGet]; 17 | export type ShortHandComputedField5 = [string, string, string, string, string, ComputedFieldGet]; 18 | export type ShortHandComputedField6 = [string, string, string, string, string, string, ComputedFieldGet]; 19 | export type ShortHandComputedField = ShortHandComputedField1 | ShortHandComputedField2 | ShortHandComputedField3 | ShortHandComputedField4 | ShortHandComputedField5 | ShortHandComputedField6; 20 | export type ComputedDefs = { 21 | computed: Record; 22 | }; 23 | /** 24 | * @typedef ComputedStaticMixin 25 | * @property {ComputedDefs} computed 26 | */ 27 | /** 28 | * @template {typeof Model} BaseClass 29 | * @param {BaseClass} ctorOrDescriptor - Base model class 30 | * @returns {BaseClass & ComputedStaticMixin} 31 | */ 32 | export function withComputed(ctorOrDescriptor: BaseClass): BaseClass & ComputedStaticMixin; 33 | import type { Model } from './nextbone.js'; 34 | //# sourceMappingURL=computed.d.ts.map -------------------------------------------------------------------------------- /types/computed.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"computed.d.ts","sourceRoot":"","sources":["../computed.js"],"names":[],"mappings":";cAyLc,YAAY;;wCAjLf,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KACjB,GAAG;;aAGF,MAAM,EAAE;SACR,gBAAgB;;;;SAChB,CAAC,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,GAAG;;sCAEjD,CAAC,MAAM,EAAE,gBAAgB,CAAC;sCAC1B,CAAC,MAAM,EAAE,MAAM,EAAE,gBAAgB,CAAC;sCAClC,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,CAAC;sCAC1C,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,CAAC;sCAClD,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,CAAC;sCAC1D,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,CAAC;qCAClE,uBAAuB,GAAG,uBAAuB,GAAG,uBAAuB,GAAG,uBAAuB,GAAG,uBAAuB,GAAG,uBAAuB;;cAGxJ,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,sBAAsB,CAAC;;AA8JpE;;;GAGG;AAEH;;;;GAIG;AACH,6BAJ4B,SAAS,SAAxB,YAAc,oBAChB,SAAS,GACP,SAAS,GAAG,mBAAmB,CAc3C;2BA1MyB,eAAe"} -------------------------------------------------------------------------------- /types/dom-utils.d.ts: -------------------------------------------------------------------------------- 1 | export class Region { 2 | constructor(targetEl: any, currentEl?: any); 3 | targetEl: any; 4 | isSwappingEl: boolean; 5 | currentEl: any; 6 | show(el: any): void; 7 | empty(): void; 8 | attachEl(el: any): void; 9 | detachEl(el: any): void; 10 | } 11 | //# sourceMappingURL=dom-utils.d.ts.map -------------------------------------------------------------------------------- /types/dom-utils.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"dom-utils.d.ts","sourceRoot":"","sources":["../dom-utils.js"],"names":[],"mappings":"AAAA;IACE,4CAIC;IAHC,cAAwB;IACxB,sBAAyB;IACzB,eAA0B;IAG5B,oBAWC;IAED,cAKC;IAED,wBAEC;IAED,wBAEC;CACF"} -------------------------------------------------------------------------------- /types/form.d.ts: -------------------------------------------------------------------------------- 1 | export function getPath(object: any, path: string): any; 2 | export function setPath(object: any, path: string, value: any): void; 3 | export function getPathChange(obj: any, path: any, value: any): any[]; 4 | export class FormState { 5 | /** 6 | * @param {HTMLElement} el 7 | * @param {FormStateOptions} options 8 | */ 9 | constructor(el: HTMLElement, { model, updateMethod, inputs, events }?: FormStateOptions); 10 | _data: {}; 11 | _attributes: Set; 12 | _modelInstance: any; 13 | el: HTMLElement; 14 | model: string | Model; 15 | events: { 16 | event: string; 17 | selector: string; 18 | }[]; 19 | updateMethod: string; 20 | get modelInstance(): any; 21 | acceptInput(prop: any, event: any): boolean; 22 | getAttributes(): any[]; 23 | __selector: string; 24 | get(attr: any, { meta }?: { 25 | meta: any; 26 | }): any; 27 | set(attr: any, value: any, { meta, reset, silent }?: { 28 | meta: any; 29 | reset: any; 30 | silent: any; 31 | }): void; 32 | _ensureInitialData(model: any): void; 33 | /** 34 | * @param {string} attr 35 | * @returns {any} 36 | * @deprecated 37 | * @see FormState#get 38 | */ 39 | getValue(attr: string): any; 40 | /** 41 | * @param {string} attr 42 | * @param {any} value 43 | * @returns {void} 44 | * @deprecated 45 | * @see FormState#set 46 | */ 47 | setValue(attr: string, value: any): void; 48 | /** 49 | * @param {string} prop 50 | * @returns {any} 51 | * @see FormState#getData 52 | * @see FormState#getValue 53 | * @see FormState#get 54 | * @see FormState#getValue 55 | */ 56 | getData(prop: string): any; 57 | /** 58 | * @param {string} prop 59 | * @param {*} value 60 | */ 61 | setData(prop: string, value: any): void; 62 | /** 63 | * @return {boolean} 64 | */ 65 | isDirty(): boolean; 66 | /** 67 | * @returns {string[]} 68 | */ 69 | getDirtyAttributes(): string[]; 70 | /** 71 | * @param {Object} options 72 | * @param {string[]} [options.attributes] 73 | * @param {boolean} [options.update] 74 | * @param {boolean} [options.touch] 75 | * @returns {boolean} 76 | */ 77 | isValid({ attributes, update, touch }?: { 78 | attributes?: string[]; 79 | update?: boolean; 80 | touch?: boolean; 81 | }): boolean; 82 | loadInitialData(): void; 83 | reset(): void; 84 | errors: {}; 85 | touched: {}; 86 | modelInitialData: WeakMap; 87 | } 88 | export function registerFormat(name: any, fn: any): void; 89 | export function registerInput(selector: any, events: any): void; 90 | export function form(optionsOrCtorOrDescriptor: FormStateOptions, options: any): ClassDecorator; 91 | export type FormStateOptions = { 92 | model?: string | Model; 93 | updateMethod?: string; 94 | inputs?: Record; 95 | events?: Array<{ 96 | event: string; 97 | selector: string; 98 | }>; 99 | }; 100 | import type { Model } from './nextbone.js'; 101 | import type { Model as Model_1 } from './nextbone.js'; 102 | //# sourceMappingURL=form.d.ts.map -------------------------------------------------------------------------------- /types/form.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"form.d.ts","sourceRoot":"","sources":["../form.js"],"names":[],"mappings":"AAqCO,gCAJI,GAAC,QACD,MAAM,GACJ,GAAG,CAOf;AAQM,gCALI,GAAC,QACD,MAAM,SACN,GAAG,GACD,IAAI,CAoBhB;AAEM,sEAyBN;AA0HD;IACE;;;OAGG;IACH,gBAHW,WAAW,4CACX,gBAAgB,EAsB1B;IAXC,UAAe;IACf,sBAA4B;IAC5B,oBAA+B;IAC/B,gBAAY;IACZ,sBAAkB;IAClB;eAjOwB,MAAM;kBAAY,MAAM;QAiO5B;IACpB,qBAAgC;IAOlC,yBAKC;IAED,4CAEC;IAED,uBAaC;IAXG,mBAAqE;IAazE;;YAEC;IAED;;;;aAmBC;IAED,qCAKC;IAED;;;;;OAKG;IACH,eALW,MAAM,GACJ,GAAG,CAMf;IAED;;;;;;OAMG;IACH,eANW,MAAM,SACN,GAAG,GACD,IAAI,CAMhB;IAED;;;;;;;OAOG;IACH,cAPW,MAAM,GACJ,GAAG,CAQf;IAED;;;OAGG;IACH,cAHW,MAAM,SACN,GAAC,QAIX;IAED;;OAEG;IACH,WAFY,OAAO,CAMlB;IAED;;OAEG;IACH,sBAFa,MAAM,EAAE,CAiBpB;IAED;;;;;;OAMG;IACH,wCALG;QAA2B,UAAU,GAA7B,MAAM,EAAE;QACU,MAAM,GAAxB,OAAO;QACW,KAAK,GAAvB,OAAO;KACf,GAAU,OAAO,CAmBnB;IAED,wBAGC;IAED,cAKC;IAJC,WAAgB;IAChB,YAAiB;IAEjB,uCAAqC;CAExC;AAaM,yDAEN;AAEM,gEAGN;AAOM,gDAJI,gBAAgB,iBACd,cAAc,CAwB1B;;YAjca,MAAM,GAAG,KAAK;mBACd,MAAM;aACN,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC;aACxB,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAC,CAAC;;2BAN5B,eAAe;sCAAf,eAAe"} -------------------------------------------------------------------------------- /types/localstorage.d.ts: -------------------------------------------------------------------------------- 1 | /** Generate a pseudo-guid 2 | * @returns {string} A GUID-like string. 3 | */ 4 | export function guid(): string; 5 | export function bindLocalStorage(instance: any, name: any, { serializer, initialData }?: { 6 | serializer: any; 7 | initialData: any; 8 | }): void; 9 | export function localStorage(name: any, options: any): (ctorOrDescriptor: any) => { 10 | new (...args: any[]): { 11 | [x: string]: any; 12 | }; 13 | [x: string]: any; 14 | } | { 15 | kind: any; 16 | elements: any; 17 | finisher(ctor: any): { 18 | new (...args: any[]): { 19 | [x: string]: any; 20 | }; 21 | [x: string]: any; 22 | }; 23 | }; 24 | //# sourceMappingURL=localstorage.d.ts.map -------------------------------------------------------------------------------- /types/localstorage.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"localstorage.d.ts","sourceRoot":"","sources":["../localstorage.js"],"names":[],"mappings":"AAUA;;GAEG;AACH,wBAFa,MAAM,CAIlB;AAwCD;;;SAOC;AA8OM;;;;;;;;;;;;;;EAYN"} -------------------------------------------------------------------------------- /types/utils.d.ts: -------------------------------------------------------------------------------- 1 | export function deepCloneLite(obj: any, level?: number): {}; 2 | export function cloneObject(obj: any): any; 3 | //# sourceMappingURL=utils.d.ts.map -------------------------------------------------------------------------------- /types/utils.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../utils.js"],"names":[],"mappings":"AAOA,4DAaC;AAlBD,2CAEC"} -------------------------------------------------------------------------------- /types/validation.d.ts: -------------------------------------------------------------------------------- 1 | export type FnRule = (this: Model, value: any, attr: string, computed: Record) => any; 2 | export type ValidationRule = { 3 | /** 4 | * - If the attribute is required or not 5 | */ 6 | required?: boolean | FnRule; 7 | /** 8 | * - If the attribute has to be accepted 9 | */ 10 | acceptance?: boolean | FnRule; 11 | /** 12 | * - The minimum value for the attribute 13 | */ 14 | min?: number | FnRule; 15 | /** 16 | * - The maximum value for the attribute 17 | */ 18 | max?: number | FnRule; 19 | /** 20 | * - The range for the attribute] 21 | */ 22 | range?: number[] | FnRule; 23 | /** 24 | * - The length for the attribute 25 | */ 26 | length?: number | FnRule; 27 | /** 28 | * - The minimum length for the attribute 29 | */ 30 | minLength?: number | FnRule; 31 | /** 32 | * - The maximum length for the attribute 33 | */ 34 | maxLength?: number | FnRule; 35 | /** 36 | * - The range for the length of the attribute 37 | */ 38 | rangeLength?: number[] | FnRule; 39 | /** 40 | * - The allowed values for the attribute 41 | */ 42 | oneOf?: string[] | FnRule; 43 | /** 44 | * - The name of the attribute to compare with 45 | */ 46 | equalTo?: string | FnRule; 47 | /** 48 | * - The pattern to match the attribute against 49 | */ 50 | pattern?: RegExp | string | FnRule; 51 | /** 52 | * - The error message to display if the validation fails 53 | */ 54 | msg?: string; 55 | /** 56 | * - A custom function used for validation 57 | */ 58 | fn?: FnRule; 59 | }; 60 | export type ValidationRules = Record; 61 | export type ValidationStaticMixin = { 62 | validation: ValidationRules; 63 | }; 64 | /** 65 | * @typedef ValidationStaticMixin 66 | * @property {ValidationRules} validation 67 | */ 68 | /** 69 | * @template {typeof Model} BaseClass 70 | * @param {BaseClass} ctorOrDescriptor - Base model class 71 | * @returns {BaseClass & ValidationStaticMixin} 72 | */ 73 | export function withValidation(ctorOrDescriptor: BaseClass): BaseClass & ValidationStaticMixin; 74 | export namespace labelFormatters { 75 | function none(attrName: any): any; 76 | function sentenceCase(attrName: any): any; 77 | function label(attrName: any, model: any): any; 78 | } 79 | export namespace messages { 80 | let required: string; 81 | let acceptance: string; 82 | let min: string; 83 | let max: string; 84 | let range: string; 85 | let length: string; 86 | let minLength: string; 87 | let maxLength: string; 88 | let rangeLength: string; 89 | let oneOf: string; 90 | let equalTo: string; 91 | let digits: string; 92 | let number: string; 93 | let email: string; 94 | let url: string; 95 | let inlinePattern: string; 96 | } 97 | export namespace validators { 98 | function format(text: any, ...args: any[]): any; 99 | function formatLabel(attrName: any, model: any): any; 100 | } 101 | export namespace patterns { 102 | let digits_1: RegExp; 103 | export { digits_1 as digits }; 104 | let number_1: RegExp; 105 | export { number_1 as number }; 106 | let email_1: RegExp; 107 | export { email_1 as email }; 108 | let url_1: RegExp; 109 | export { url_1 as url }; 110 | } 111 | export namespace options { 112 | let labelFormatter: string; 113 | let valid: Function; 114 | let invalid: Function; 115 | } 116 | import type { Model } from './nextbone.js'; 117 | //# sourceMappingURL=validation.d.ts.map -------------------------------------------------------------------------------- /types/validation.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../validation.js"],"names":[],"mappings":"0CAsBW,GAAC,QACD,MAAM,YACN,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC;;;;;eAIhB,OAAO,GAAC,MAAM;;;;iBACd,OAAO,GAAC,MAAM;;;;UACd,MAAM,GAAC,MAAM;;;;UACb,MAAM,GAAC,MAAM;;;;YACb,MAAM,EAAE,GAAC,MAAM;;;;aACf,MAAM,GAAC,MAAM;;;;gBACb,MAAM,GAAC,MAAM;;;;gBACb,MAAM,GAAC,MAAM;;;;kBACb,MAAM,EAAE,GAAC,MAAM;;;;YACf,MAAM,EAAE,GAAC,MAAM;;;;cACf,MAAM,GAAC,MAAM;;;;cACb,MAAM,GAAC,MAAM,GAAC,MAAM;;;;UACpB,MAAM;;;;SACN,MAAM;;8BAEP,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC;;gBA6hB7B,eAAe;;AAF7B;;;GAGG;AAEH;;;;GAIG;AACH,+BAJ4B,SAAS,SAAxB,YAAc,oBAChB,SAAS,GACP,SAAS,GAAG,qBAAqB,CAc7C;;IA5VO,kCAEL;IAGa,0CAMb;IAeM,+CAIN;;;;;;;;;;;;;;;;;;;;;IAjDH,gDAIC;IAVD,qDAEC;;;;;;;;;;;;;;;;;2BAxNyB,eAAe"} -------------------------------------------------------------------------------- /types/virtualcollection.d.ts: -------------------------------------------------------------------------------- 1 | export type VirtualCollectionOptions = { 2 | filter?: ModelFilter; 3 | destroyWith?: Model | Collection; 4 | comparator?: isFunction; 5 | /** 6 | * ;} [model] 7 | */ 8 | "": new (...args: any[]) => Model | ((...args: any[]) => Model); 9 | }; 10 | export type ModelFilterFunction = (model: Model) => any; 11 | export type ModelFilter = Record | ModelFilterFunction; 12 | /** 13 | * @class VirtualCollection 14 | * @description A virtual collection is a collection that is a filtered view of another collection. 15 | */ 16 | export class VirtualCollection extends Collection { 17 | /** 18 | * @param {Collection | null} [parent] 19 | * @param {VirtualCollectionOptions} [options] 20 | */ 21 | constructor(parent?: Collection | null, options?: VirtualCollectionOptions); 22 | /** @type {Collection} */ 23 | _parent: Collection; 24 | accepts: any; 25 | set parent(value: Collection); 26 | /** 27 | * @type {Collection} 28 | */ 29 | get parent(): Collection; 30 | /** 31 | * @param {ModelFilter} filter 32 | * @returns {VirtualCollection} 33 | */ 34 | updateFilter(filter: ModelFilter): VirtualCollection; 35 | _rebuildIndex(): void; 36 | orderViaParent(options: any): void; 37 | _onSort(collection: any, options: any): void; 38 | _proxyParentEvents(collection: any, events: any): void; 39 | _clearChangesCache(): void; 40 | _changeCache: { 41 | added: any[]; 42 | removed: any[]; 43 | merged: any[]; 44 | }; 45 | _onUpdate(collection: any, options: any): void; 46 | _onAdd(model: any, collection: any, options: any): void; 47 | _onRemove(model: any, collection: any, options: any): void; 48 | _onChange(model: any, options: any): void; 49 | _onReset(collection: any, options: any): void; 50 | _onFilter(collection: any, options: any): void; 51 | sortedIndex(model: any, value: any, context: any): any; 52 | _indexAdd(model: any): void; 53 | _indexRemove(model: any): any; 54 | _onAllEvent(eventName: any, ...args: any[]): void; 55 | } 56 | export function buildFilter(options: any): any; 57 | export function virtualState(optionsOrProtoOrDescriptor: any, fieldName: any, options: any): ((protoOrDescriptor: any) => any | { 58 | kind: any; 59 | placement: any; 60 | descriptor: any; 61 | initializer: any; 62 | key: string | symbol; 63 | finisher(ctor: any): any; 64 | }) | { 65 | kind: any; 66 | placement: any; 67 | descriptor: any; 68 | initializer: any; 69 | key: string | symbol; 70 | finisher(ctor: any): any; 71 | }; 72 | import type { Model } from './nextbone.js'; 73 | import { Collection } from './nextbone.js'; 74 | //# sourceMappingURL=virtualcollection.d.ts.map -------------------------------------------------------------------------------- /types/virtualcollection.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"virtualcollection.d.ts","sourceRoot":"","sources":["../virtualcollection.js"],"names":[],"mappings":";aAOc,WAAW;kBACX,KAAK,GAAG,UAAU;iBAClB,WAAqB,KAAK,CAAC;;;;QAC3B,KAAK,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,KAAK,GAAG,CAAC,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,KAAK,CAAC;;0CAI9D,KAAK;0BAGH,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,mBAAmB;AAyBtD;;;GAGG;AACH;IAIE;;;OAGG;IACH,qBAHW,UAAU,GAAG,IAAI,YACjB,wBAAwB,EAWlC;IAhBD,yBAAyB;IACzB,SADW,UAAU,CACb;IAaN,aAAsD;IAWxD,8BAiBC;IAxBD;;OAEG;IACH,yBAEC;IAqBD;;;OAGG;IACH,qBAHW,WAAW,GACT,iBAAiB,CAU7B;IAED,sBAcC;IAED,mCAKC;IAED,6CAGC;IAED,uDAOC;IAED,2BAMC;IALC;;;;MAIC;IAGH,+CAOC;IAED,wDASC;IAED,2DAQC;IAED,0CAmBC;IAED,8CAGC;IAED,+CAGC;IAED,uDAWC;IAED,4BAsBC;IAED,8BASC;IAED,kDAIC;CAKF;AAlPD,+CAcC;AAqYD;;;;;;;;;;;;;;EA2BC;2BApc+C,eAAe;2BAJ5B,eAAe"} -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | import { isPlainObject } from 'lodash-es'; 2 | 3 | function cloneObject(obj) { 4 | return Object.assign({}, obj); 5 | } 6 | 7 | // clone that deep copy array and object one level 8 | function deepCloneLite(obj, level = 1) { 9 | var result = {}; 10 | Object.keys(obj).forEach(key => { 11 | var value = obj[key]; 12 | if (Array.isArray(value)) { 13 | result[key] = value.slice(0); 14 | } else if (isPlainObject(value)) { 15 | result[key] = level > 0 ? deepCloneLite(value, level - 1) : cloneObject(value); 16 | } else { 17 | result[key] = value; 18 | } 19 | }); 20 | return result; 21 | } 22 | 23 | export { deepCloneLite, cloneObject }; 24 | -------------------------------------------------------------------------------- /web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | import { babel as rollupBabel } from '@rollup/plugin-babel'; 2 | import { fromRollup } from '@web/dev-server-rollup'; 3 | 4 | const babel = fromRollup(rollupBabel); 5 | 6 | export default { 7 | plugins: [babel({ include: ['test/**/*.js'], babelHelpers: 'inline' })] 8 | }; 9 | --------------------------------------------------------------------------------