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 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()