knockout-rails 0.0.5 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/HISTORY.md +7 -1
- data/README.md +125 -2
- data/lib/assets/javascripts/knockout/model.js.coffee +44 -5
- data/lib/assets/javascripts/knockout/validations.js.coffee +67 -0
- data/lib/assets/javascripts/knockout/validators.js.coffee +84 -0
- data/lib/knockout-rails/version.rb +1 -1
- data/spec/javascripts/knockout/bindings/autosave_spec.js.coffee +1 -1
- data/spec/javascripts/knockout/model_spec.js.coffee +32 -8
- data/spec/javascripts/knockout/validations_spec.js.coffee +90 -0
- data/spec/javascripts/knockout/validators_spec.js.coffee +200 -0
- data/vendor/assets/javascripts/knockout/knockout.js +3223 -3176
- metadata +20 -14
data/HISTORY.md
CHANGED
@@ -1,9 +1,15 @@
|
|
1
1
|
# x.y.z - in progress
|
2
2
|
|
3
3
|
- Support collections (fetch multiple records)
|
4
|
-
- Client side validation
|
5
4
|
- JST templating support
|
6
5
|
|
6
|
+
|
7
|
+
# 1.0.0 - 22 December 2011
|
8
|
+
|
9
|
+
- Breaking change: `@configure` should be `@persistAt`
|
10
|
+
- Custom model events and callbacks
|
11
|
+
- Declarative client side validation
|
12
|
+
|
7
13
|
# 0.0.4-5 - 20 December 2011
|
8
14
|
|
9
15
|
- Fix to inplace edit to be able to switch back to view mode when no value has changed
|
data/README.md
CHANGED
@@ -25,7 +25,8 @@ After you've referenced the `knockout` you can create your first persistent Mode
|
|
25
25
|
|
26
26
|
```coffee
|
27
27
|
class @Page extends ko.Model
|
28
|
-
@
|
28
|
+
@persistAt 'page' # This is enough to save the model RESTfully to `/pages/{id}` URL
|
29
|
+
@fields 'id', 'name', 'whatever' # This is optional and will be inferred if not used
|
29
30
|
```
|
30
31
|
|
31
32
|
Too simple. This model conforms to the response of [inherited_resources](https://github.com/josevalim/inherited_resources) Gem.
|
@@ -71,6 +72,128 @@ Now let's see how we can show the validation errors on the page and bind everyth
|
|
71
72
|
%span.inline-error{:data=>{:bind=>'visible: errors.name, text: errors.name'}}
|
72
73
|
```
|
73
74
|
|
75
|
+
## Model Validations
|
76
|
+
|
77
|
+
If you are using the model, you can also take advantage of the client-side validation framework.
|
78
|
+
|
79
|
+
The client side validation works similarly to the server-side validation.
|
80
|
+
This means there is only one place to check for errors, no matter where those are defined.
|
81
|
+
|
82
|
+
For example - `page.errors.name()` returns the error message for the `name` field for both client and server side validations.
|
83
|
+
|
84
|
+
The piece of code below should explain client-side validation, including some of the options.
|
85
|
+
|
86
|
+
```coffee
|
87
|
+
class @Page extends ko.Model
|
88
|
+
@persistAt 'page'
|
89
|
+
|
90
|
+
@validates: ->
|
91
|
+
@acceptance 'agree_to_terms' # Value is truthy
|
92
|
+
@presence 'name', 'body' # Non-empty, non-blank stringish value
|
93
|
+
@email 'author' # Valid email, blanks allowed
|
94
|
+
|
95
|
+
@presence 'password'
|
96
|
+
@confirmation 'passwordConfirmation', {confirms: 'password'} # Blanks allowed
|
97
|
+
|
98
|
+
# numericality:
|
99
|
+
@numericality 'rating'
|
100
|
+
@numericality 'rating', min: 1, max: 5
|
101
|
+
|
102
|
+
# Inclusion/exclusion
|
103
|
+
@inclusion 'subdomain', values: ["mine", "yours"]
|
104
|
+
@exclusion 'subdomain', values: ["www", "www2"]
|
105
|
+
|
106
|
+
@format 'code', match: /\d+/ # Regex validation, blanks allowed
|
107
|
+
@length 'name', min: 3, max: 10 # Stringish value should be with the range
|
108
|
+
|
109
|
+
# Custom message
|
110
|
+
@presence 'name', message: 'give me a name, yo!'
|
111
|
+
|
112
|
+
# Conditional validation - access model using `this`
|
113
|
+
@presence 'name', only: -> @persisted(), except: -> @id() > 5
|
114
|
+
|
115
|
+
# Custom inline validation
|
116
|
+
@custom 'name', (page) ->
|
117
|
+
if (page.name() || '').indexOf('funky') < 0 then "should be funky" else null
|
118
|
+
```
|
119
|
+
|
120
|
+
It is recommended to avoid custom inline validations and create your own validators instead (and maybe submit it as a Pull Request):
|
121
|
+
|
122
|
+
|
123
|
+
```coffee
|
124
|
+
ko.Validations.validators.funky = (model, field, options) ->
|
125
|
+
# options - is an optional set of options passed to the validator
|
126
|
+
word = options.word || 'funky'
|
127
|
+
if model[field]().indexOf(word) < 0 "should be #{word}" else null
|
128
|
+
```
|
129
|
+
|
130
|
+
so that you can use it like so:
|
131
|
+
|
132
|
+
```coffee
|
133
|
+
@validates: ->
|
134
|
+
funky 'name', word: 'yakk'
|
135
|
+
```
|
136
|
+
|
137
|
+
Here's how you would check whether the model is valid or not (assuming presence validation on `name` field):
|
138
|
+
|
139
|
+
```coffee
|
140
|
+
page = new @Page name: ''
|
141
|
+
page.isValid() # false
|
142
|
+
page.errors.name() # "can't be blank"
|
143
|
+
|
144
|
+
page.name = 'Home'
|
145
|
+
page.isValid() # true
|
146
|
+
page.errors.name() # null
|
147
|
+
|
148
|
+
```
|
149
|
+
|
150
|
+
Every validator has its own set of options. But the following are applied to all of them (including yours):
|
151
|
+
|
152
|
+
- `only: -> truthy or falsy` - only apply the validation when the condition is truthy. `this` points to the model so you can access it.
|
153
|
+
- `except:` - is the opposite to only. Both `only` and `except` can be used, but you should make sure those are not mutually exclusive.
|
154
|
+
|
155
|
+
|
156
|
+
And at the end of this exercise, you can bind the errors using `data-bind="text: page.error.name"` or any other technique.
|
157
|
+
|
158
|
+
## Model Events
|
159
|
+
|
160
|
+
```coffee
|
161
|
+
class @Page extends ko.Model
|
162
|
+
@persistAt 'page'
|
163
|
+
|
164
|
+
# Subscribe to 'sayHi' event
|
165
|
+
@upon 'sayHi', (name) ->
|
166
|
+
alert name + @name
|
167
|
+
|
168
|
+
page = Page.new name: 'Home'
|
169
|
+
page.trigger 'sayHi', 'Hi '
|
170
|
+
# will show "Hi Home"
|
171
|
+
|
172
|
+
```
|
173
|
+
|
174
|
+
|
175
|
+
## Model Callbacks
|
176
|
+
|
177
|
+
The callbacks are just convenience wrappers over the predefined events.
|
178
|
+
Some of them are:
|
179
|
+
|
180
|
+
```coffee
|
181
|
+
class @Page extends ko.Model
|
182
|
+
@persistAt 'page'
|
183
|
+
|
184
|
+
@beforeSave ->
|
185
|
+
@age = @birthdate - new Date()
|
186
|
+
|
187
|
+
# This would be similar to
|
188
|
+
|
189
|
+
class @Page extends ko.Model
|
190
|
+
@persistAt 'page'
|
191
|
+
|
192
|
+
@on 'beforeSave', ->
|
193
|
+
@age = @birthdate - new Date()
|
194
|
+
```
|
195
|
+
|
196
|
+
|
74
197
|
## Bindings
|
75
198
|
|
76
199
|
This gem also includes useful bindings that you may find useful in your application.
|
@@ -81,7 +204,7 @@ Or if you want to include all of the bindings available, then require `knockout/
|
|
81
204
|
The list of currently available bindings:
|
82
205
|
|
83
206
|
- `autosave` - automatically persists the model whenever any of its attributes change.
|
84
|
-
Apply it to a `form` element. Examples: `autosave: page`, `autosave: {model: page, when: page.isEnabled, unless: viewModel.doNotSave }`.
|
207
|
+
Apply it to a `form` element. Examples: `autosave: page`, `autosave: {model: page, when: page.isEnabled, unless: viewModel.doNotSave }`. *NOTE*: It will not save when a model is not valid.
|
85
208
|
- `inplace` - converts the input elements into inplace editing with 'Edit'/'Done' buttons. Apply it on `input` elements similarly to the `value` binding.
|
86
209
|
- `color` - converts an element into a color picker. Apply it to a `div` element: `color: page.fontColor`. Depends on [pakunok](https://github.com/dnagir/pakunok) gem (specifically - its `colorpicker` asset).
|
87
210
|
- `onoff` - Converts checkboxes into iOS on/off buttons. Example: `onoff: page.isPublic`. It depends on [ios-chechboxes](https://github.com/dnagir/ios-checkboxes) gem.
|
@@ -1,4 +1,6 @@
|
|
1
1
|
#=require jquery
|
2
|
+
#=require knockout/validations
|
3
|
+
#=require knockout/validators
|
2
4
|
|
3
5
|
# Module is taken from Spine.js
|
4
6
|
moduleKeywords = ['included', 'extended']
|
@@ -17,10 +19,31 @@ class Module
|
|
17
19
|
obj.extended?.apply(@)
|
18
20
|
@
|
19
21
|
|
22
|
+
Events =
|
23
|
+
ClassMethods:
|
24
|
+
extended: ->
|
25
|
+
@events ||= {}
|
26
|
+
@include Events.InstanceMethods
|
27
|
+
upon: (eventName, callback) ->
|
28
|
+
@events[eventName] || = []
|
29
|
+
@events[eventName].push callback
|
30
|
+
this # Just to chain it if we need to
|
31
|
+
|
32
|
+
InstanceMethods:
|
33
|
+
trigger: (eventName, args...) ->
|
34
|
+
events = @constructor.events
|
35
|
+
handlers = events[eventName] || []
|
36
|
+
callback.apply(this, args) for callback in handlers
|
37
|
+
this # so that we can chain
|
38
|
+
|
39
|
+
|
40
|
+
Callbacks =
|
41
|
+
ClassMethods:
|
42
|
+
beforeSave: (callback) -> @upon('beforeSave', callback)
|
20
43
|
|
21
44
|
Ajax =
|
22
45
|
ClassMethods:
|
23
|
-
|
46
|
+
persistAt: (@className) ->
|
24
47
|
@getUrl ||= (model) ->
|
25
48
|
return model.getUrl(model) if model and model.getUrl
|
26
49
|
collectionUrl = "/#{className.toLowerCase()}s"
|
@@ -42,6 +65,9 @@ Ajax =
|
|
42
65
|
toJSON: -> ko.mapping.toJS @, @mapping()
|
43
66
|
|
44
67
|
save: ->
|
68
|
+
allowSaving = @isValid()
|
69
|
+
return false unless allowSaving
|
70
|
+
@trigger('beforeSave') # Consider moving it into the beforeSend or similar
|
45
71
|
data = {}
|
46
72
|
data[@constructor.className] =@toJSON()
|
47
73
|
params =
|
@@ -70,11 +96,19 @@ Ajax =
|
|
70
96
|
|
71
97
|
class Model extends Module
|
72
98
|
@extend Ajax.ClassMethods
|
99
|
+
@extend Events.ClassMethods
|
100
|
+
@extend Callbacks.ClassMethods
|
101
|
+
@extend ko.Validations.ClassMethods
|
102
|
+
|
103
|
+
@fields: (fieldNames...) ->
|
104
|
+
fieldNames = fieldNames.flatten() # when a single arg is given as an array
|
105
|
+
@fieldNames = fieldNames
|
73
106
|
|
74
107
|
constructor: (json) ->
|
75
108
|
me = this
|
76
109
|
@set json
|
77
110
|
@id ||= ko.observable()
|
111
|
+
# Overly Heavy, heavy binding to `this`...
|
78
112
|
@mapping().ignore.exclude('constructor').filter (v)->
|
79
113
|
not v.startsWith('_') and Object.isFunction me[v]
|
80
114
|
.forEach (fn) ->
|
@@ -85,15 +119,19 @@ class Model extends Module
|
|
85
119
|
|
86
120
|
@persisted = ko.dependentObservable -> !!me.id()
|
87
121
|
|
88
|
-
proxy: -> @mapping().ignore
|
89
|
-
|
90
122
|
set: (json) ->
|
91
123
|
ko.mapping.fromJS json, @mapping(), @
|
92
124
|
me = this
|
93
125
|
@errors ||= {}
|
94
126
|
ignores = @mapping().ignore
|
95
|
-
|
96
|
-
|
127
|
+
availableFields = @constructor.fieldNames
|
128
|
+
availableFields ||= @constructor.fields Object.keys(json) # Configure fields unless done manually
|
129
|
+
|
130
|
+
#for key, value of json
|
131
|
+
for key in availableFields when ignores.indexOf(key) < 0
|
132
|
+
@[key] ||= ko.observable()
|
133
|
+
@errors[key] ||= ko.observable()
|
134
|
+
@enableValidations()
|
97
135
|
@
|
98
136
|
|
99
137
|
updateErrors: (errorData) ->
|
@@ -111,3 +149,4 @@ class Model extends Module
|
|
111
149
|
# Export it all:
|
112
150
|
ko.Module = Module
|
113
151
|
ko.Model = Model
|
152
|
+
ko.Events = Events
|
@@ -0,0 +1,67 @@
|
|
1
|
+
class ValidationContext
|
2
|
+
|
3
|
+
constructor: (@subject) ->
|
4
|
+
|
5
|
+
getDsl: (source = ko.Validations.validators) ->
|
6
|
+
dsl = {}
|
7
|
+
me = this
|
8
|
+
@wrapValidator dsl, name, func for name, func of source
|
9
|
+
dsl
|
10
|
+
|
11
|
+
wrapValidator: (dsl, name, func)->
|
12
|
+
me = this
|
13
|
+
dsl[name] = (fields..., options) ->
|
14
|
+
if typeof(options) is 'string'
|
15
|
+
# Last argument isn't `options` - it's a field
|
16
|
+
fields.push options
|
17
|
+
options = {}
|
18
|
+
me.setValidator(func, field, options) for field in fields
|
19
|
+
dsl
|
20
|
+
|
21
|
+
setValidator: (validator, field, options) ->
|
22
|
+
me = this
|
23
|
+
me._validations ||= {}
|
24
|
+
me._validations[field] ||= []
|
25
|
+
|
26
|
+
validatorSubscriber = ko.dependentObservable ->
|
27
|
+
{only, except} = options
|
28
|
+
allowedByOnly = !only or only.call(me.subject)
|
29
|
+
deniedByExcept = except and except.call(me.subject)
|
30
|
+
|
31
|
+
shouldValidate = allowedByOnly and not deniedByExcept
|
32
|
+
|
33
|
+
validator.call(me, me.subject, field, options) if shouldValidate
|
34
|
+
|
35
|
+
validatorSubscriber.subscribe (newError) ->
|
36
|
+
currentError = me.subject.errors[field]
|
37
|
+
actualError = [currentError(), newError].exclude((x) -> !x).join(", ")
|
38
|
+
me.subject.errors[field]( actualError or null)
|
39
|
+
|
40
|
+
# Clear the error only once before the value gets changed
|
41
|
+
validatorSubscriber.subscribe ->
|
42
|
+
me.subject.errors[field](null)
|
43
|
+
, me.subject, "beforeChange" if me._validations[field].isEmpty()
|
44
|
+
|
45
|
+
me._validations[field].push validatorSubscriber
|
46
|
+
me
|
47
|
+
|
48
|
+
|
49
|
+
Validations =
|
50
|
+
ClassMethods:
|
51
|
+
extended: -> @include Validations.InstanceMethods
|
52
|
+
|
53
|
+
InstanceMethods:
|
54
|
+
isValid: ->
|
55
|
+
return true unless @errors
|
56
|
+
for key, value of @errors
|
57
|
+
return false unless Object.isEmpty value()
|
58
|
+
return true
|
59
|
+
|
60
|
+
enableValidations: ->
|
61
|
+
return false unless @constructor.validates
|
62
|
+
@validationContext = new ValidationContext(this)
|
63
|
+
dsl = @validationContext.getDsl()
|
64
|
+
@constructor.validates.call(dsl, this)
|
65
|
+
true
|
66
|
+
|
67
|
+
ko.Validations = Validations
|
@@ -0,0 +1,84 @@
|
|
1
|
+
#= require knockout/validations
|
2
|
+
|
3
|
+
ko.Validations.validators =
|
4
|
+
acceptance: (model, field, options) ->
|
5
|
+
val = model[field]()
|
6
|
+
unless val then options.message || "needs to be accepted" else null
|
7
|
+
|
8
|
+
presence: (model, field, options) ->
|
9
|
+
val = model[field]()
|
10
|
+
isBlank = !val or val.toString().isBlank()
|
11
|
+
if isBlank then options.message || "can't be blank" else null
|
12
|
+
|
13
|
+
|
14
|
+
email: (model, field, options) ->
|
15
|
+
val = model[field]()
|
16
|
+
isValid = !val or val.toString().match /.+@.+\..+/
|
17
|
+
unless isValid then options.message or "should be a valid email" else null
|
18
|
+
|
19
|
+
|
20
|
+
confirmation: (model, field, options) ->
|
21
|
+
otherField = options.confirms
|
22
|
+
throw "Please specify which field to apply the confirmation to using {confirms: 'otherField'}" unless otherField
|
23
|
+
orig = model[field]()
|
24
|
+
other = model[otherField]()
|
25
|
+
if orig != other and orig then options.message or "should confirm #{otherField}" else null
|
26
|
+
|
27
|
+
|
28
|
+
numericality: (model, field, options) ->
|
29
|
+
val = model[field]()
|
30
|
+
return unless val
|
31
|
+
looksLikeNumeric = val.toString().match /^-?\d+$/ # We should do better than this
|
32
|
+
num = parseInt val, 10
|
33
|
+
min = if options.min? then options.min else num
|
34
|
+
max = if options.max? then options.max else num
|
35
|
+
if looksLikeNumeric and min <= num <= max then null else options.message or "should be numeric"
|
36
|
+
|
37
|
+
|
38
|
+
inclusion: (model, field, options) ->
|
39
|
+
values = options.values
|
40
|
+
throw "Please specify the values {values: [1, 2, 5]}" unless values
|
41
|
+
val = model[field]()
|
42
|
+
return unless val
|
43
|
+
if values.indexOf(val) < 0 then options.message or "should be one of #{values.join(', ')}" else null
|
44
|
+
|
45
|
+
|
46
|
+
exclusion: (model, field, options) ->
|
47
|
+
values = options.values
|
48
|
+
throw "Please specify the values {values: [1, 2, 5]}" unless values
|
49
|
+
val = model[field]()
|
50
|
+
return unless val
|
51
|
+
if values.indexOf(val) >= 0 then options.message or "should not be any of #{values.join(', ')}" else null
|
52
|
+
|
53
|
+
format: (model, field, options) ->
|
54
|
+
matcher = options.match
|
55
|
+
throw "Please specify the match RegEx {match: /\d+/}" unless matcher
|
56
|
+
val = model[field]()
|
57
|
+
return unless val
|
58
|
+
if val.toString().match matcher then null else options.message or "should be formatted properly"
|
59
|
+
|
60
|
+
length: (model, field, options) ->
|
61
|
+
val = model[field]()
|
62
|
+
return unless val
|
63
|
+
val = val.toString().length
|
64
|
+
{min, max} = options
|
65
|
+
min = val unless min?
|
66
|
+
max = val unless max?
|
67
|
+
createMsg = ->
|
68
|
+
minMsg = if options.min?
|
69
|
+
"at least #{min} characters long"
|
70
|
+
else
|
71
|
+
""
|
72
|
+
maxMsg = if options.max?
|
73
|
+
"no longer than #{max} characters"
|
74
|
+
else
|
75
|
+
""
|
76
|
+
separator = if minMsg and maxMsg then " but " else ""
|
77
|
+
"should be #{minMsg}#{separator}#{maxMsg}"
|
78
|
+
if min <= val <= max then null else options.message or createMsg()
|
79
|
+
|
80
|
+
custom: (model, field, options) ->
|
81
|
+
# Treat options as a mandatory callback
|
82
|
+
options.call(model, model)
|
83
|
+
|
84
|
+
|
@@ -1,10 +1,16 @@
|
|
1
1
|
class Page extends ko.Model
|
2
|
-
|
2
|
+
@persistAt 'page'
|
3
|
+
@upon 'sayHi', (hi) ->
|
4
|
+
@sayHi = hi
|
5
|
+
|
6
|
+
@beforeSave ->
|
7
|
+
@beforeSaved = true
|
3
8
|
|
4
9
|
|
5
10
|
describe "Model", ->
|
6
11
|
|
7
12
|
beforeEach ->
|
13
|
+
jasmine.Ajax.useMock()
|
8
14
|
@page = new Page
|
9
15
|
id: 123
|
10
16
|
name: 'Home'
|
@@ -24,9 +30,6 @@ describe "Model", ->
|
|
24
30
|
expect(@page.persisted()).toBeFalsy()
|
25
31
|
|
26
32
|
describe "Ajax", ->
|
27
|
-
beforeEach ->
|
28
|
-
jasmine.Ajax.useMock()
|
29
|
-
|
30
33
|
it "should return jQuery deferred when saving", ->
|
31
34
|
expect( @page.save().done ).toBeTruthy()
|
32
35
|
|
@@ -56,7 +59,20 @@ describe "Model", ->
|
|
56
59
|
name: 'Home'
|
57
60
|
content: 'Hello'
|
58
61
|
|
62
|
+
it "should not save if invalid", ->
|
63
|
+
@page.errors.name 'whatever'
|
64
|
+
expect(@page.save()).toBeFalsy()
|
65
|
+
expect(mostRecentAjaxRequest()).toBeFalsy()
|
66
|
+
|
59
67
|
describe "errors", ->
|
68
|
+
|
69
|
+
it "should have errors on fields only", ->
|
70
|
+
keys = Object.keys(@page.errors)
|
71
|
+
expect(keys).toContain 'id'
|
72
|
+
expect(keys).toContain 'name'
|
73
|
+
expect(keys).toContain 'content'
|
74
|
+
expect(keys.length).toBe 3
|
75
|
+
|
60
76
|
it "should have errors for fields", ->
|
61
77
|
e = @page.errors
|
62
78
|
e.name('a')
|
@@ -66,13 +82,12 @@ describe "Model", ->
|
|
66
82
|
expect(e.content()).toBe 'b'
|
67
83
|
|
68
84
|
describe "on 200 response", ->
|
69
|
-
it "should
|
70
|
-
@page.
|
71
|
-
@page.save() # Probably we should not allow to save in the first place
|
85
|
+
it "should be valid", ->
|
86
|
+
@page.save()
|
72
87
|
mostRecentAjaxRequest().response
|
73
88
|
status: 200
|
74
89
|
responseText: "{}"
|
75
|
-
expect( @page.
|
90
|
+
expect( @page.isValid() ).toBeTruthy()
|
76
91
|
|
77
92
|
|
78
93
|
describe "on 422 resposne (unprocessible entity = validation error)", ->
|
@@ -83,3 +98,12 @@ describe "Model", ->
|
|
83
98
|
responseText: '{"name": ["got ya", "really"]}'
|
84
99
|
expect( @page.errors.name() ).toBe "got ya, really"
|
85
100
|
|
101
|
+
describe "events", ->
|
102
|
+
it "should raise events", ->
|
103
|
+
@page.trigger('sayHi', 'abc')
|
104
|
+
expect(@page.sayHi).toBe 'abc'
|
105
|
+
|
106
|
+
describe "callbacks", ->
|
107
|
+
it "beforeSave should be called ", ->
|
108
|
+
@page.save()
|
109
|
+
expect(@page.beforeSaved).toBeTruthy()
|