pakyow-ui 0.10.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: efeadfe62f8a75592def0245762f51a0ed6810d0
4
+ data.tar.gz: 9cc0f94cdeac4337d04dae90c43172d50dca1c81
5
+ SHA512:
6
+ metadata.gz: 456f564c2ce44a45f96e1374db0441a6ae764ef5dbaef49506f4a6dd9a16fdc7fac9f61b07929d274ade2ee3c44b91d22d112572be419d8a054896e9f4c03797
7
+ data.tar.gz: 6ab2b517de51cc4b2310b4f6517c39ad7ba0cdb60497be90dfbf638b6cdb6dcf0f8ad9a09cee3af87c30e5a9e75feb5e3342220830cb65acdb2d246df06e6812
@@ -0,0 +1,3 @@
1
+ # 0.10.0 (to be released)
2
+
3
+ * Initial release
data/pakyow-ui/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2015 Bryan Powell
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,325 @@
1
+ # PakyowUI
2
+
3
+ Auto-Updating UIs for Pakyow, without moving to the client.
4
+
5
+ ## Rationale
6
+
7
+ We wanted a way to build modern apps in a traditional backend-driven
8
+ manner while still reaping the benefits of modern, live updating
9
+ UIs. The existing approaches to building live UIs tend to *replace*
10
+ backend-driven architecture rather than *extend* it.
11
+
12
+ Instead of replacing traditional architecture, PakyowUI adds a layer
13
+ on top of it. This allows Pakyow apps to have live UIs out of the box
14
+ without any additional work by the developer, while remaining accessible
15
+ and fully usable in the absence of WebSockets, JavaScript, etc.
16
+
17
+ The PakyowUI approach stays true to the fundamental nature of the web.
18
+ It aims to be the real-time web expressed as progressive enhancement.
19
+ It allows for any aspect of a website or web app to be updated in
20
+ real-time, without any additional work by the developer.
21
+
22
+ ## Overview
23
+
24
+ At a high level, PakyowUI keeps rendered views (meaning views that
25
+ are currently rendered by a client in the browser) in sync with the
26
+ current state of the data.
27
+
28
+ During the initial request/response cycle, Pakyow keeps track of what
29
+ view is rendered and sent back to the client, along with the underlying
30
+ data used to render those views.
31
+
32
+ When the data changes in the future, a set of transformation
33
+ instructions are sent to the client(s) who possess views with that
34
+ data. The transformations are then applied to the existing views by a
35
+ JavaScript client library so that the view not reflects the current
36
+ app state. The app *does not* push re-rendered views back down to the
37
+ client, nor does any JavaScript transcompilation occur.
38
+
39
+ ---
40
+
41
+ Let's look at an example. Say during the initial request/response cycle
42
+ Pakyow rendered a view that presents a user's name:
43
+
44
+ ```html
45
+ <div data-scope="user" data-id="1">
46
+ <h3 data-prop="name">
47
+ Bob Dylan
48
+ </h3>
49
+ </div>
50
+ ```
51
+
52
+ When the name changes, PakyowUI builds up an instruction like this:
53
+
54
+ ```ruby
55
+ [[:bind, { name: 'Thelonius Monk' }]]
56
+ ```
57
+
58
+ This instruction is routed to the client(s) that present a view
59
+ containing data for user 1. Once received, the intructions are applied.
60
+
61
+ Predictably, the updated view reflects the new state:
62
+
63
+ ```html
64
+ <div data-scope="user" data-id="1">
65
+ <h3 data-prop="name">
66
+ Thelonius Monk
67
+ </h3>
68
+ </div>
69
+ ```
70
+
71
+ PakyowUI builds on the [pakyow-realtime
72
+ library](https://github.com/pakyow/pakyow/tree/master/pakyow-realtime),
73
+ so all of the communication between client and server occurs over a
74
+ WebSocket. If WebSockets aren't supported by the client (or for some
75
+ reason aren't working) the app will continue to work, just without live
76
+ updates. You get this graceful degradation for free without developing
77
+ with progressive enhancement in mind.
78
+
79
+ ---
80
+
81
+ By expressing view transformations as data, they can be applied to
82
+ any view by any interpreter; be it on the server or the client. To
83
+ accomplish this, view rendering must be expressed separately from
84
+ the view and in context of the data being presented by the view. The
85
+ rendering itself also is expressed independently of how to fetch
86
+ the data necessary to perform the render, giving PakyowUI all the
87
+ information it needs to automatically perform the rendering again at
88
+ some point in the future.
89
+
90
+ ## Data - Mutables
91
+
92
+ PakyowUI introduces a concept called **mutables**. A mutable wraps a
93
+ data model and defines two things:
94
+
95
+ 1. Actions that can occur on the model that cause state mutations.
96
+ 2. Queries that define how particular data is to be fetched.
97
+
98
+ Here's how a mutable is defined:
99
+
100
+ ```ruby
101
+ class User < Sequel::Model; end
102
+
103
+ Pakyow::App.mutable :user do
104
+ model User
105
+
106
+ action :create do |object|
107
+ User.create(object)
108
+ end
109
+
110
+ query :all do
111
+ User.all
112
+ end
113
+
114
+ query :find do |id|
115
+ User[id]
116
+ end
117
+ end
118
+ ```
119
+
120
+ From a route, we can use the mutable to query for data:
121
+
122
+ ```ruby
123
+ # get all the users
124
+ data(:user).all
125
+
126
+ # get a specific user
127
+ data(:user).find(1)
128
+ ```
129
+
130
+ We can also change data through the mutable:
131
+
132
+ ```ruby
133
+ data(:user).create(params[:user])
134
+ ```
135
+
136
+ Mutables are the first step in making the route declarative (what)
137
+ rather than imperative (how). All of the *how* is wrapped up in the
138
+ mutable itself, letting us express only *what* should happen from the
139
+ route. This is important.
140
+
141
+ ## View - Mutators
142
+
143
+ The second concept introduced by PakyowUI is **mutators**. A mutator
144
+ describes *how* to render a particular view with some particular data.
145
+
146
+ Here's a mutator for rendering a list of users:
147
+
148
+ ```ruby
149
+ Pakyow::Mutators :user do
150
+ mutator :list do |view, users|
151
+ view.apply(users)
152
+ end
153
+ end
154
+ ```
155
+
156
+ From a route, you could invoke the mutator on a view like this:
157
+
158
+ ```ruby
159
+ view.scope(:user).mutate(:list, with: data(:user).all)
160
+ ```
161
+
162
+ Notice that we're mutating with the data from our mutable user. Pakyow
163
+ will fetch the data using the `all` query and pass it to the `list`
164
+ mutation where the view is rendered.
165
+
166
+ ***NOTE:*** One important caveat here is that the individual data
167
+ passed to the mutate method needs to respond to the `to_hash` method,
168
+ which should return a hash of all relevant attributes. E.g. For
169
+ ActiveRecord models could define `to_hash` like this:
170
+
171
+ ```
172
+ class User < ActiveRecord::Base
173
+ def to_hash
174
+ attributes
175
+ end
176
+ end
177
+ ```
178
+
179
+ At this point we've effectively turned view rendering into a declarative
180
+ action from the route's point of view. We only have to describe *what*
181
+ we want to happen and Pakyow takes care of the rest.
182
+
183
+ This becomes useful when you want to subscribe the mutation to future
184
+ changes in state. You can do this by calling `subscribe`:
185
+
186
+ ```ruby
187
+ view.scope(:user).mutate(:list, with: data(:user).all).subscribe
188
+ ```
189
+
190
+ The view is rendered in the intial request/response cycle exactly like
191
+ it was before, but now it's also subscribed to any future state change
192
+ that affects the rendered view.
193
+
194
+ Let's mutate our state:
195
+
196
+ ```ruby
197
+ data(:user).create(params[:user])
198
+ ```
199
+
200
+ Pakyow knows that we've mutated our user state; it also knows what
201
+ clients are currently rendering the mutated user state. It automatically
202
+ pushes down instructions over a WebSocket describing how the client
203
+ should update their view to match the current state.
204
+
205
+ Our rendered views now keep themselves up to date with the current state
206
+ of the application; and we as the developer don't have to do anything
207
+ but write the initial rendering code! We don't have to move any part of
208
+ our app to the client. Our app retains a backend-driven architecture
209
+ while still behaving like a modern app with live updates.
210
+
211
+ ## Ring - Client Library
212
+
213
+ PakyowUI ships with a client library called Ring, effectively
214
+ bringing Pakyow's view transformation API to the client. In addition
215
+ to applying view transformations, Pakyow.js also ships with several
216
+ components, including:
217
+
218
+ - Mutation Detection: Watches user-interaction with the rendered view and can
219
+ interpret which interactions cause a mutation in state (e.g. submitting a
220
+ form). Once detected, the mutation is sent to the server by calling the REST
221
+ endpoint through the open WebSocket. The mutation is then validated by the
222
+ server, persisted (if necessary), and broadcast to all other clients.
223
+
224
+ You can use Ring to build custom front-end components that emit
225
+ their own mutations or otherwise communicate with your app's HTTP routes
226
+ over a WebSocket.
227
+
228
+ Ring is available here:
229
+
230
+ - http://github.com/pakyow/ring).
231
+
232
+ ## Channels
233
+
234
+ Pakyow keeps track of what clients should receive what state mutations
235
+ with channels. Here's how a channel is structured:
236
+
237
+ ```
238
+ scope:{name};mutation{name}::{qualifiers}
239
+ ```
240
+
241
+ In the example from the Mutators section, the subscribed channel name is:
242
+
243
+ ```
244
+ scope:user;mutation:list
245
+ ```
246
+
247
+ This means that any client who rendered any user data with the `list`
248
+ mutation will receive future updates in user state. Read the next
249
+ section to understand how to better control this.
250
+
251
+ ## Qualifiers
252
+
253
+ You might be curious about how to exercise fine-grained control over
254
+ clients and the mutations they receive. PakyowUI handles this with
255
+ *qualifiers*.
256
+
257
+ For example, you can subscribe a view to only update with the current
258
+ user's data:
259
+
260
+ ```ruby
261
+ view.scope(:user).mutate(:list, with:
262
+ data(:user).for_user(current_user)).subscribe({
263
+ user_id: current_user.id
264
+ })
265
+ ```
266
+
267
+ The `user_id` qualifier is added to the channel name, so when future
268
+ mutations occur, the result will only be pushed down to that particular
269
+ client. Here's the subscribed channel name:
270
+
271
+ scope:user;mutation:present::user_id:1
272
+
273
+ You can also qualify mutators. Here's how you would express that you
274
+ want a particular user's mutations to be sent only to clients that
275
+ render that state:
276
+
277
+ ```ruby
278
+ Pakyow::Mutators :user do
279
+ mutator :present, qualify: [:id] do |view, user|
280
+ view.bind(user)
281
+ end
282
+ end
283
+
284
+ view.scope(:user).mutate(:present, with: data(:user).find(1)).subscribe
285
+ ```
286
+
287
+ The value for the qualifier will be pulled from the user's id and added to the
288
+ channel name. Now only client's who currently render the user with id of 1 will
289
+ receive future state changes about that user. Here's the subscribed channel
290
+ name:
291
+
292
+ ```
293
+ scope:user;mutation:present::id:1
294
+ ```
295
+
296
+ # Download
297
+
298
+ The latest version of Pakyow UI can be installed with RubyGems:
299
+
300
+ ```
301
+ gem install pakyow-ui
302
+ ```
303
+
304
+ Source code can be downloaded as part of the Pakyow project on Github:
305
+
306
+ - https://github.com/pakyow/pakyow/tree/master/pakyow-ui
307
+
308
+ # License
309
+
310
+ Pakyow UI is released free and open-source under the [MIT
311
+ License](http://opensource.org/licenses/MIT).
312
+
313
+ # Support
314
+
315
+ Documentation is available here:
316
+
317
+ - http://pakyow.org/docs/live-views
318
+
319
+ Found a bug? Tell us about it here:
320
+
321
+ - https://github.com/pakyow/pakyow/issues
322
+
323
+ We'd love to have you in the community:
324
+
325
+ - http://pakyow.org/get-involved
@@ -0,0 +1,35 @@
1
+ require_relative 'helpers'
2
+ require_relative 'ui'
3
+ require_relative 'mutator'
4
+ require_relative 'mutation_set'
5
+ require_relative 'mutable'
6
+ require_relative 'mutate_context'
7
+ require_relative 'ui_view'
8
+ require_relative 'channel_builder'
9
+ require_relative 'fetch_view_handler'
10
+ require_relative 'mutation_store'
11
+ require_relative 'registries/simple_mutation_registry'
12
+ require_relative 'registries/redis_mutation_registry'
13
+ require_relative 'config'
14
+ require_relative 'ui_component'
15
+ require_relative 'ui_instructable'
16
+
17
+ require_relative 'ext/app'
18
+ require_relative 'ext/app_context'
19
+ require_relative 'ext/view_context'
20
+
21
+ Pakyow::App.before :init do
22
+ @ui = Pakyow::UI::UI.new
23
+ end
24
+
25
+ Pakyow::App.after :load do
26
+ @ui.load(mutators, mutables)
27
+ end
28
+
29
+ Pakyow::App.before :route do
30
+ # setup a new ui context to work in
31
+ #
32
+ ui_dup = @ui.dup
33
+ ui_dup.context = self
34
+ @context.ui = ui_dup
35
+ end
@@ -0,0 +1,54 @@
1
+ module Pakyow
2
+ module UI
3
+ # Helpers for building channel names.
4
+ #
5
+ # @api private
6
+ module ChannelBuilder
7
+ PARTS = [:scope, :mutation, :component]
8
+
9
+ def self.build(qualifiers: [], data: [], qualifications: {}, **args)
10
+ channel = []
11
+ channel_extras = []
12
+
13
+ PARTS.each do |part|
14
+ add_part(part, args[part], channel)
15
+ end
16
+
17
+ add_qualifiers(qualifiers, data, channel_extras)
18
+ add_qualifications(qualifications, channel_extras)
19
+
20
+ channel = channel.join(';')
21
+
22
+ return channel if channel_extras.empty?
23
+ channel << "::#{channel_extras.join(';')}"
24
+ end
25
+
26
+ private
27
+
28
+ def self.add_part(part, value, channel)
29
+ return if value.nil?
30
+ channel << "#{part}:#{value}"
31
+ end
32
+
33
+ def self.add_qualifiers(qualifiers, data, channel_extras)
34
+ qualifiers = Array.ensure(qualifiers)
35
+
36
+ data = data.data if data.is_a?(Pakyow::UI::MutableData)
37
+ return if qualifiers.empty? || data.empty?
38
+
39
+ datum = Array.ensure(data).first
40
+
41
+ qualifiers.each do |qualifier|
42
+ channel_extras << "#{qualifier}:#{datum[qualifier]}"
43
+ end
44
+ end
45
+
46
+ def self.add_qualifications(qualifications, channel_extras)
47
+ qualifications.each do |name, value|
48
+ next if value.nil?
49
+ channel_extras << "#{name}:#{value}"
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'registries/simple_mutation_registry'
2
+ require_relative 'registries/redis_mutation_registry'
3
+
4
+ Pakyow::Config.register(:ui) { |config|
5
+ # The registry to use when keeping up with connections.
6
+ config.opt :registry, Pakyow::UI::SimpleMutationRegistry
7
+ }.env(:development) { |opts|
8
+ opts.registry = Pakyow::UI::SimpleMutationRegistry
9
+ }.env(:staging) { |opts|
10
+ opts.registry = Pakyow::UI::RedisMutationRegistry
11
+ }.env(:production) { |opts|
12
+ opts.registry = Pakyow::UI::RedisMutationRegistry
13
+ }
@@ -0,0 +1,50 @@
1
+ module Pakyow
2
+ class App
3
+ class << self
4
+ # Defines mutators for a scope.
5
+ #
6
+ # @api public
7
+ def mutators(scope = nil, &block)
8
+ @mutators ||= {}
9
+
10
+ if scope && block
11
+ @mutators[scope] = block
12
+ else
13
+ @mutators || {}
14
+ end
15
+ end
16
+
17
+ # Defines a mutable object.
18
+ #
19
+ # @api public
20
+ def mutable(scope, &block)
21
+ @mutables ||= {}
22
+ @mutables[scope] = block
23
+ end
24
+
25
+ # @api private
26
+ def mutables
27
+ @mutables || {}
28
+ end
29
+ end
30
+
31
+ # Convenience method for defining mutators on an app instance.
32
+ #
33
+ # @api public
34
+ def mutators(scope = nil, &block)
35
+ self.class.mutators(scope, &block)
36
+ end
37
+
38
+ # Convenience method for defining a mutable on an app instance.
39
+ #
40
+ # @api public
41
+ def mutable(scope, &block)
42
+ self.class.mutable(scope, &block)
43
+ end
44
+
45
+ # @api private
46
+ def mutables
47
+ self.class.mutables
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ module Pakyow
2
+ class AppContext
3
+ attr_accessor :ui
4
+ end
5
+ end
@@ -0,0 +1,30 @@
1
+ module Pakyow
2
+ module Presenter
3
+ class ViewContext
4
+ MSG_NONCOMPONENT = 'Cannot subscribe a non-component view'
5
+
6
+ # Mutates a view with a registered mutator.
7
+ #
8
+ # @api public
9
+ def mutate(mutator, data: nil, with: nil)
10
+ Pakyow::UI::Mutator.instance.mutate(mutator, self, data || with || [])
11
+ end
12
+
13
+ # Subscribes a view and sets the `data-channel` attribute.
14
+ #
15
+ # @api public
16
+ def subscribe(qualifications = {})
17
+ fail ArgumentError, MSG_NONCOMPONENT unless component?
18
+
19
+ channel = Pakyow::UI::ChannelBuilder.build(
20
+ component: component_name,
21
+ qualifications: qualifications
22
+ )
23
+
24
+ context.socket.subscribe(channel)
25
+ attrs.send(:'data-channel=', channel)
26
+ self
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,67 @@
1
+ require_relative 'no_op_view'
2
+
3
+ # Makes it possible to fetch a particular part of a view for a path. Calls a
4
+ # route with all view actions becoming no-ops. Then a query is run against the
5
+ # final view, pulling out the part that was requested.
6
+ #
7
+ # Expects the following in the message:
8
+ #
9
+ # - uri: the route to call
10
+ # - lookup: the view query
11
+ #
12
+ # Lookup currently supports the following keys:
13
+ #
14
+ # - channel
15
+ # - version
16
+ # - container
17
+ # - partial
18
+ # - scope
19
+ # - prop
20
+ #
21
+ Pakyow::Realtime.handler :'fetch-view' do |message, session, response|
22
+ env = Rack::MockRequest.env_for(message['uri'])
23
+ env['rack.session'] = session
24
+
25
+ app = Pakyow.app.dup
26
+
27
+ def app.view
28
+ Pakyow::Presenter::NoOpView.new(
29
+ Pakyow::Presenter::ViewContext.new(@presenter.view, self),
30
+ self
31
+ )
32
+ end
33
+
34
+ app_response = app.process(env)
35
+
36
+ body = ''
37
+ lookup = message['lookup']
38
+ view = app.presenter.view
39
+
40
+ channel = lookup['channel']
41
+
42
+ if channel
43
+ unqualified_channel = channel.split('::')[0]
44
+ view_for_channel = view.composed.doc.channel(unqualified_channel)
45
+
46
+ if view_for_channel
47
+ view_for_channel.set_attribute(:'data-channel', channel)
48
+ body = view_for_channel.to_html
49
+ end
50
+ else
51
+ lookup.each_pair do |key, value|
52
+ next if key == 'version'
53
+ view = view.send(key.to_sym, value.to_sym)
54
+ end
55
+
56
+ if view.is_a?(Pakyow::Presenter::ViewVersion)
57
+ body = view.use(lookup['version'] || :default).to_html
58
+ else
59
+ body = view.to_html
60
+ end
61
+ end
62
+
63
+ response[:status] = app_response[0]
64
+ response[:headers] = app_response[1]
65
+ response[:body] = body
66
+ response
67
+ end
@@ -0,0 +1,11 @@
1
+ module Pakyow
2
+ module Helpers
3
+ def ui
4
+ context.ui
5
+ end
6
+
7
+ def data(scope)
8
+ ui.mutator.mutable(scope, self)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,25 @@
1
+ module Pakyow
2
+ module Presenter
3
+ # Used by NoOpView to perform mutations in a no-op manner.
4
+ #
5
+ # @api private
6
+ class MockMutationEval
7
+ def initialize(mutation_name, relation_name, view)
8
+ @mutation_name = mutation_name
9
+ @relation_name = relation_name
10
+ @view = view
11
+ end
12
+
13
+ # NOTE we don't care about qualifiers here since we're just getting
14
+ # the proper view template; not actually setting it up with data
15
+ def subscribe(*_args)
16
+ channel = Pakyow::UI::ChannelBuilder.build(
17
+ scope: @view.scoped_as,
18
+ mutation: @mutation_name
19
+ )
20
+
21
+ @view.attrs.send(:'data-channel=', channel)
22
+ end
23
+ end
24
+ end
25
+ end