pakyow-ui 0.10.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,99 @@
1
+ require_relative 'mutable_data'
2
+
3
+ # TODO: make it possible to register this as data instead of mutables
4
+
5
+ module Pakyow
6
+ module UI
7
+ # Mutables enable PakyowUI to automatically handle changes in application
8
+ # state by interacting with the data layer in a declarative manner.
9
+ #
10
+ # Wraps a data source (such as a model object) and provides a convenient
11
+ # interface for defining and executing queries and actions. Queries accept
12
+ # parameters and return data sets. Actions cause a state change in
13
+ # application state.
14
+ #
15
+ # Once defined, all interactions with the data layer should occur through
16
+ # Mutables via the `data` helper method. When an action is performed that
17
+ # changes the state of the application, Pakyow will propogate the change
18
+ # through to all other connected clients automatically.
19
+ #
20
+ # Mutables should be registered with the `Pakyow::App.mutable` helper. The
21
+ # defined block will be executed in context of a `Mutable` instance.
22
+ #
23
+ # @api public
24
+ class Mutable
25
+ include Helpers
26
+
27
+ attr_reader :context
28
+
29
+ # @api private
30
+ def initialize(context, scope, &block)
31
+ @context = context
32
+ @scope = scope
33
+ @actions = {}
34
+ @queries = {}
35
+
36
+ instance_exec(&block)
37
+ end
38
+
39
+ # Sets the model object.
40
+ #
41
+ # @api public
42
+ def model(model_class, type: nil)
43
+ @model_class = model_class
44
+
45
+ return if type.nil?
46
+ @model_type = type
47
+
48
+ # TODO: load default actions / queries based on type
49
+ end
50
+
51
+ # Defines an action.
52
+ #
53
+ # @api public
54
+ def action(name, mutation: true, &block)
55
+ @actions[name] = {
56
+ block: block,
57
+ mutation: mutation
58
+ }
59
+ end
60
+
61
+ # Defines a query.
62
+ #
63
+ # @api public
64
+ def query(name, &block)
65
+ @queries[name] = block
66
+ end
67
+
68
+ # Handles calling queries or actions. Enables convenience like:
69
+ #
70
+ # data(:some_data).{action or query}
71
+ #
72
+ # @api public
73
+ def method_missing(method, *args)
74
+ action = @actions[method]
75
+ query = @queries[method]
76
+
77
+ if action
78
+ call_action(action, *args)
79
+ elsif query
80
+ call_query(query, method, *args)
81
+ else
82
+ fail ArgumentError, "Could not find query or action named #{method}"
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def call_action(action, *args)
89
+ result = action[:block].call(*args)
90
+ @context.ui.mutated(@scope, result, @context) if action[:mutation]
91
+ result
92
+ end
93
+
94
+ def call_query(query, method, *args)
95
+ MutableData.new(query, method, args, @scope)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,21 @@
1
+ module Pakyow
2
+ module UI
3
+ # Adds metadata to a dataset returned by a Mutable query.
4
+ #
5
+ # @api private
6
+ class MutableData
7
+ attr_reader :query_name, :query_args, :scope
8
+
9
+ def initialize(query, query_name, query_args, scope)
10
+ @query = query
11
+ @query_name = query_name
12
+ @query_args = query_args
13
+ @scope = scope
14
+ end
15
+
16
+ def data
17
+ @data ||= @query.call(*@query_args)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,79 @@
1
+ require_relative 'channel_builder'
2
+
3
+ module Pakyow
4
+ module UI
5
+ # Provides helper methods to perform in context of a mutation. For example:
6
+ #
7
+ # view.scope(:foo).mutate(:bar).subscribe
8
+ #
9
+ # In the above example `mutate` returns a MutateContext object on which
10
+ # `subscribe` is called.
11
+ #
12
+ # @api public
13
+ class MutateContext
14
+ attr_reader :mutation, :view, :data
15
+
16
+ # Creates a new context. Intended to be created by a Mutator.
17
+ #
18
+ # @api private
19
+ def initialize(mutation, view, data)
20
+ @mutation = mutation
21
+ @view = view
22
+ @data = data
23
+ end
24
+
25
+ # Subscribes a mutation with optional qualifications. Qualifications are
26
+ # used to control the scope of future mutations. For example:
27
+ #
28
+ # view.scope(:foo).mutate(:bar).subscribe(user_id: 1)
29
+ #
30
+ # In the above example, a subscription is created qualified by `user_id`.
31
+ # Only mutations occuring with the same qualifications will cause the
32
+ # mutation to be performed again, triggering a view refresh.
33
+ #
34
+ # ui.mutated(:foo, user_id: 1)
35
+ #
36
+ # @api public
37
+ def subscribe(qualifications = {})
38
+ if data.is_a?(MutableData)
39
+ MutationStore.instance.register(self, data, qualifications)
40
+ end
41
+
42
+ channel = ChannelBuilder.build(
43
+ scope: view.scoped_as,
44
+ mutation: mutation[:name],
45
+ qualifiers: mutation[:qualifiers],
46
+ data: data,
47
+ qualifications: qualifications
48
+ )
49
+
50
+ # subscribe to the channel
51
+ view.context.socket.subscribe(channel)
52
+
53
+ # handle setting the channel on the view
54
+ if view.is_a?(Presenter::ViewContext)
55
+ working_view = view.instance_variable_get(:@view)
56
+ else
57
+ working_view = view
58
+ end
59
+
60
+ if working_view.is_a?(Presenter::ViewCollection)
61
+ # NOTE there's a special case here where if the collection is
62
+ # empty we insert an empty element in its place; this makes
63
+ # it possible to know what the data should be applied to when
64
+ # a mutation occurs in the future
65
+
66
+ unless working_view.exists?
67
+ # TODO: would rather this be an html comment, but they aren't
68
+ # supported by query selectors; need to finalize how we will
69
+ # handle this particular edge case
70
+ working_view.first.doc.append('<span data-channel="' + channel + '" data-version="empty"></span>')
71
+ return
72
+ end
73
+ end
74
+
75
+ working_view.attrs.send(:'data-channel=', channel)
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,38 @@
1
+ module Pakyow
2
+ module UI
3
+ # Stores mutations.
4
+ #
5
+ # @api private
6
+ class MutationSet
7
+ attr_reader :mutations
8
+
9
+ def initialize(&block)
10
+ @mutations = {}
11
+ instance_exec(&block)
12
+ end
13
+
14
+ # NOTE I do have some concerns about defining qualifiers in this way;
15
+ # mainly because it will lead to having lots of versions of the same
16
+ # mutator just so the proper channels will be created.
17
+ #
18
+ # It's could end up being better to pass qualifiers to `subscribe`;
19
+ # however it feels premature to make this decision since it'll lead
20
+ # to a large increase in complexity to add at this point.
21
+ def mutator(name, qualify: [], &block)
22
+ @mutations[name] = {
23
+ fn: block,
24
+ qualifiers: Array.ensure(qualify),
25
+ name: name
26
+ }
27
+ end
28
+
29
+ def mutation(name)
30
+ @mutations.fetch(name)
31
+ end
32
+
33
+ def each(&block)
34
+ @mutations.each(&block)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,30 @@
1
+ module Pakyow
2
+ module UI
3
+ # Stores mutations that have occurred in the configured registry.
4
+ #
5
+ # @api private
6
+ class MutationStore
7
+ include Singleton
8
+
9
+ def initialize
10
+ @registry = Config.ui.registry.instance
11
+ end
12
+
13
+ def register(mutate_context, mutable_data, qualifications)
14
+ # TODO: decide how we'll clean these up as clients disconnect
15
+ @registry.register(
16
+ mutable_data.scope,
17
+ mutation: mutate_context.mutation[:name],
18
+ qualifiers: mutate_context.mutation[:qualifiers],
19
+ qualifications: qualifications,
20
+ query_name: mutable_data.query_name,
21
+ query_args: mutable_data.query_args
22
+ )
23
+ end
24
+
25
+ def mutations(scope)
26
+ @registry.mutations(scope) || []
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,63 @@
1
+ require_relative 'mutation_set'
2
+ require_relative 'mutate_context'
3
+
4
+ module Pakyow
5
+ module UI
6
+ # Performs mutations on views.
7
+ #
8
+ # @api private
9
+ class Mutator
10
+ include Singleton
11
+
12
+ attr_reader :sets
13
+
14
+ # @api private
15
+ def initialize
16
+ reset
17
+ end
18
+
19
+ def reset
20
+ @sets = {}
21
+ @mutables = {}
22
+ self
23
+ end
24
+
25
+ def set(scope, &block)
26
+ @sets[scope] = MutationSet.new(&block)
27
+ end
28
+
29
+ def mutable(scope, context = nil, &block)
30
+ if block_given?
31
+ @mutables[scope] = block
32
+ else
33
+ # TODO: inefficient to have to execute the block each time
34
+ Mutable.new(context, scope, &@mutables[scope])
35
+ end
36
+ end
37
+
38
+ def mutation(scope, name)
39
+ if mutations = mutations_by_scope(scope)
40
+ mutations.mutation(name)
41
+ end
42
+ end
43
+
44
+ # TODO: rename to mutation_set_for_scope
45
+ def mutations_by_scope(scope)
46
+ @sets[scope]
47
+ end
48
+
49
+ def mutate(mutation_name, view, data)
50
+ if mutation = mutation(view.scoped_as, mutation_name)
51
+ if data.is_a?(MutableData)
52
+ working_data = data.data
53
+ else
54
+ working_data = data
55
+ end
56
+
57
+ result = mutation[:fn].call(view, working_data)
58
+ MutateContext.new(mutation, result, data)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,87 @@
1
+ require_relative 'mock_mutation_eval'
2
+
3
+ module Pakyow
4
+ module Presenter
5
+ # Stands in for a real View object and makes any attempted transformation
6
+ # a no-op.
7
+ #
8
+ # @api private
9
+ class NoOpView
10
+ include Helpers
11
+ VIEW_CLASSES = [ViewContext]
12
+
13
+ # The arities of misc view methods that switch the behavior from
14
+ # instance_exec to yield.
15
+ #
16
+ EXEC_ARITIES = { with: 0, for: 1, for_with_index: 2, repeat: 1,
17
+ repeat_with_index: 2, bind: 1, bind_with_index: 2,
18
+ apply: 1 }
19
+
20
+ def initialize(view, context)
21
+ @view = view
22
+ @context = context
23
+ end
24
+
25
+ def is_a?(klass)
26
+ @view.is_a?(klass)
27
+ end
28
+
29
+ # View methods that should be a no-op
30
+ #
31
+ %i(bind bind_with_index apply).each do |method|
32
+ define_method(method) do |_data, **_kargs, &_block|
33
+ self
34
+ end
35
+ end
36
+
37
+ def mutate(mutator, with: nil, data: nil)
38
+ MockMutationEval.new(mutator, with || data, self)
39
+ end
40
+
41
+ # Pass these through, handling the return value.
42
+ #
43
+ def method_missing(method, *args, &block)
44
+ ret = @view.send(method, *args, &wrap(method, &block))
45
+ handle_return_value(ret)
46
+ end
47
+
48
+ private
49
+
50
+ def view?(obj)
51
+ VIEW_CLASSES.include?(obj.class)
52
+ end
53
+
54
+ # Returns a new context for returned views, or the return value.
55
+ #
56
+ def handle_return_value(value)
57
+ return NoOpView.new(value, @context) if view?(value)
58
+
59
+ value
60
+ end
61
+
62
+ # Wrap the block, substituting the view with the current view context.
63
+ #
64
+ def wrap(method, &block)
65
+ return if block.nil?
66
+
67
+ proc do |*args|
68
+ ctx = args.map! { |arg|
69
+ view?(arg) ? NoOpView.new(arg, @context) : arg
70
+ }.find { |arg| arg.is_a?(ViewContext) }
71
+
72
+ case block.arity
73
+ when EXEC_ARITIES[method]
74
+ # Rejecting ViewContext handles the edge cases around the order of
75
+ # arguments from view methods (since view is not present in some
76
+ # situations and when it is present, is always the first arg).
77
+ ctx.instance_exec(*args.reject { |arg|
78
+ arg.is_a?(ViewContext)
79
+ }, &block)
80
+ else
81
+ block.call(*args)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,34 @@
1
+ require 'json'
2
+
3
+ module Pakyow
4
+ module UI
5
+ # Manages mutations.
6
+ #
7
+ # This is the default registry in production systems and is required in
8
+ # deployments with more than one app instance.
9
+ #
10
+ # @api private
11
+ class RedisMutationRegistry
12
+ include Singleton
13
+
14
+ def initialize
15
+ end
16
+
17
+ def register(scope, mutation)
18
+ Pakyow::Realtime.redis.sadd(key(scope), mutation.to_json)
19
+ end
20
+
21
+ def mutations(scope)
22
+ Pakyow::Realtime.redis.smembers(key(scope)).map do |m|
23
+ Hash.strhash(JSON.parse(m))
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def key(scope)
30
+ "pui-mutation-#{scope}"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,31 @@
1
+ module Pakyow
2
+ module UI
3
+ # Manages mutations.
4
+ #
5
+ # Intended only for use in development or single app-instance deployments.
6
+ #
7
+ # @api private
8
+ class SimpleMutationRegistry
9
+ include Singleton
10
+
11
+ def initialize
12
+ reset
13
+ end
14
+
15
+ def reset
16
+ @mutations = {}
17
+ end
18
+
19
+ def register(scope, mutation)
20
+ @mutations[scope] ||= []
21
+
22
+ return if @mutations[scope].include?(mutation)
23
+ @mutations[scope] << mutation
24
+ end
25
+
26
+ def mutations(scope)
27
+ @mutations[scope]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,83 @@
1
+ require_relative 'mutator'
2
+ require_relative 'channel_builder'
3
+ require_relative 'ui_view'
4
+
5
+ module Pakyow
6
+ module UI
7
+ # The UI context available during routing.
8
+ #
9
+ # @api public
10
+ class UI
11
+ attr_accessor :context
12
+ attr_reader :mutator
13
+
14
+ # Informs Pakyow that a mutation has occurred in application state,
15
+ # triggering all the necessary realtime view updates.
16
+ #
17
+ # @api public
18
+ def mutated(scope, data = nil, context = nil)
19
+ context ||= @context
20
+
21
+ MutationStore.instance.mutations(scope).each do |mutation|
22
+ view = UIView.new(scope)
23
+
24
+ qualified = true
25
+
26
+ # qualifiers are defined with the mutation
27
+ unless mutation[:qualifiers].empty? || data.nil?
28
+ mutation[:qualifiers].each_with_index do |qualifier, i|
29
+ qualified = false unless data[qualifier] == mutation[:query_args][i]
30
+ end
31
+ end
32
+
33
+ qualified = false if data.nil? && !mutation[:qualifications].empty?
34
+
35
+ # qualifications are set on the subscription
36
+ unless !qualified || mutation[:qualifications].empty? || data.nil?
37
+ mutation[:qualifications].each_pair do |key, value|
38
+ qualified = false unless data[key] == value
39
+ end
40
+ end
41
+
42
+ next unless qualified
43
+
44
+ mutable_data = Mutator.instance.mutable(scope, context).send(mutation[:query_name], *mutation[:query_args]).data
45
+ Mutator.instance.mutate(mutation[:mutation].to_sym, view, mutable_data)
46
+
47
+ Pakyow.app.socket.push(
48
+ view.finalize,
49
+
50
+ ChannelBuilder.build(
51
+ scope: scope,
52
+ mutation: mutation[:mutation].to_sym,
53
+ qualifiers: mutation[:qualifiers],
54
+ data: mutable_data,
55
+ qualifications: mutation[:qualifications]
56
+ )
57
+ )
58
+ end
59
+ end
60
+
61
+ # Addresses a component rendered on the client-side.
62
+ #
63
+ # @api public
64
+ def component(name, qualifications = {})
65
+ UIComponent.new(name, qualifications)
66
+ end
67
+
68
+ # @api private
69
+ def load(mutators, mutables)
70
+ # TODO: this is another pattern I see all over the place
71
+ @mutator = Mutator.instance.reset
72
+
73
+ mutators.each_pair do |scope, block|
74
+ @mutator.set(scope, &block)
75
+ end
76
+
77
+ mutables.each_pair do |scope, block|
78
+ @mutator.mutable(scope, &block)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,40 @@
1
+ require_relative 'ui_instructable'
2
+
3
+ module Pakyow
4
+ module UI
5
+ # Builds up instructions for changing view attributes.
6
+ #
7
+ # @api private
8
+ class UIAttrs
9
+ include Instructable
10
+
11
+ def nested_instruct_object(_method, _data, _scope)
12
+ UIAttrs.new
13
+ end
14
+
15
+ def method_missing(method, value)
16
+ nested_instruct(method, value)
17
+ end
18
+
19
+ def class
20
+ method_missing(:class, nil)
21
+ end
22
+
23
+ def id
24
+ method_missing(:id, nil)
25
+ end
26
+
27
+ def <<(value)
28
+ method_missing(:insert, value)
29
+ end
30
+
31
+ def []=(method, value)
32
+ method_missing(method, value)
33
+ end
34
+
35
+ def [](method)
36
+ method_missing(method, nil)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,68 @@
1
+ require_relative 'ui_instructable'
2
+
3
+ module Pakyow
4
+ module UI
5
+ # An object for interacting with components rendered in a browser. Custom
6
+ # messages can be pushed and will be handled by the event listener defined
7
+ # on the client-side component.
8
+ #
9
+ # It's also possible to perform view transformations in realtime. Components
10
+ # implement a subset of transformations, including `scope` and `append`.
11
+ # This allows for finer control over particular components, completely
12
+ # bypassing mutables and mutators.
13
+ #
14
+ # @api public
15
+ class UIComponent
16
+ include Instructable
17
+
18
+ attr_reader :name, :view, :qualifications
19
+
20
+ # Intended to be created through the `ui.component` helper.
21
+ #
22
+ # @api private
23
+ def initialize(name, qualifications = {})
24
+ super()
25
+ @name = name
26
+ @qualifications = qualifications
27
+ end
28
+
29
+ # Pushes a message to the component.
30
+ #
31
+ # @api public
32
+ def push(payload = nil)
33
+ payload ||= { instruct: (root || self).finalize }
34
+
35
+ Pakyow.app.socket.push(
36
+ payload,
37
+
38
+ ChannelBuilder.build(
39
+ component: name,
40
+ qualifications: qualifications
41
+ )
42
+ )
43
+ end
44
+
45
+ # Narrows the scope of component instructions.
46
+ #
47
+ # @api public
48
+ def scope(name)
49
+ nested_instruct(:scope, name.to_s, name)
50
+ end
51
+
52
+ # Other supported transformation methods.
53
+ #
54
+ # @api public
55
+ %i(append prepend).each do |method|
56
+ define_method method do |value|
57
+ instruct(method, value)
58
+ push
59
+ end
60
+ end
61
+
62
+ # @api private
63
+ def nested_instruct_object(_method, _data, _scope)
64
+ UIComponent.new(name, qualifications)
65
+ end
66
+ end
67
+ end
68
+ end