├── .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 |
70 | ${tasks.map(task => {
71 | html`- ${task.get('title')}
`
72 | })}
73 |
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 |
109 | ${tasks.map(task => {
110 | html`- ${task.get('title')}
`
111 | })}
112 |
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 |
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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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}>${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