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 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
- @configure 'page' # This is enough to save the model RESTfully to `/pages/{id}` URL
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
- configure: (@className) ->
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
- for key, value of this
96
- @errors[key] ||= ko.observable() unless ignores.indexOf(key) >= 0
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,3 +1,3 @@
1
1
  module KnockoutRails
2
- VERSION = "0.0.5"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -1,7 +1,7 @@
1
1
  #= require knockout/bindings/autosave
2
2
 
3
3
  class Page extends ko.Model
4
- @configure 'page'
4
+ @persistAt 'page'
5
5
 
6
6
  describe "AutoSave", ->
7
7
  beforeEach ->
@@ -1,10 +1,16 @@
1
1
  class Page extends ko.Model
2
- @configure 'page'
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 clear all errors", ->
70
- @page.errors.name('something is incorrect for whatever reason')
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.errors.name() ).toBeFalsy()
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()