knockout-rails 0.0.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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()
|