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.
@@ -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