quince 0.1.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 057efcbe62deb405120d389ac7186e485961d9b5b258c5ba3934d10dc88d4998
4
- data.tar.gz: 7e14d9297e3820d8d501bfd415e5f0891bc5e92b518609c895e1de45d8388605
3
+ metadata.gz: '0039bd9ca9658cfebba4048cc84fdbb095d8f2e656930501528f420c4dfa3bba'
4
+ data.tar.gz: a9720e7b11db54113fd56f1e09e43c980893297bbd5d3f46b1cdd0fc8ea2b61e
5
5
  SHA512:
6
- metadata.gz: fc197781d667ea003854e622327175332d8ba958fcf87c2385b80f635590aa95f525ed9843c7c496aa6734b2f84d07ce31638d998160e00131a7635ad79fe0e6
7
- data.tar.gz: 19678d0fabbe803917ce453e85aed1e6622c6c5729941d0a324323f0cc0e8502bf94d5194755d85fdd3d768ab31011fb4e57c58e39a57611f442569589d7b19e
6
+ metadata.gz: 6117b88cd23184b2072adae7b3b99ca868197119ffac9344477e6048519c735c7daa498067a4b96a92b60d68ce8fb5e0cc5a40529734c5c01c3b49e58317002d
7
+ data.tar.gz: f7952f3eea1cee2ef4153cbe4be1a5c709d777130f7eaa3be3ec0f5fa728da3550b81713766fcea6003673d8a65dd8a92aaae4ee3b1fb599b773463a67c5f05d
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- quince (0.1.2)
4
+ quince (0.4.0)
5
5
  oj (~> 3.13)
6
6
  typed_struct (>= 0.1.4)
7
7
 
@@ -9,7 +9,7 @@ GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
11
  diff-lcs (1.4.4)
12
- oj (3.13.5)
12
+ oj (3.13.7)
13
13
  rake (13.0.6)
14
14
  rbs (1.6.2)
15
15
  rspec (3.10.0)
data/README.md CHANGED
@@ -1,15 +1,162 @@
1
1
  # Quince
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/quince`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ ### What is Quince?
4
4
 
5
- TODO: Delete this and the text above, and describe your gem
5
+ Quince is an opinionated framework for building dynamic yet fully server-rendered web apps, with little to no JavaScript.
6
+
7
+ ### Inspired by
8
+
9
+ React, Turbo, Hotwire amongst others
10
+
11
+ ### Current status
12
+
13
+ Proof of concept, but [working in production](https://quince-rb.herokuapp.com/), and with decent performance despite few optimisations at this stage
14
+
15
+ ### How it works
16
+
17
+ - Define some components and `expose` them at certain routes
18
+ - Define some interactions that can take place, which can change the state of the components, and are handled with ruby methods
19
+ - The front end will swap out the updated components with new HTML re-rendered by the back end
20
+
21
+ ## Minimal 'hello world' example
22
+
23
+ ```ruby
24
+ # app.rb
25
+ require "quince_sinatra"
26
+
27
+ class App < Quince::Component
28
+ def render
29
+ html(
30
+ head,
31
+ body("hello world")
32
+ )
33
+ end
34
+ end
35
+
36
+ expose App, at: "/"
37
+ ```
38
+
39
+ - Run it via
40
+ ```sh
41
+ ruby app.rb
42
+ ```
43
+
44
+ - Visit `localhost:4567/`!
45
+
46
+ ## More complex example
47
+
48
+ ```ruby
49
+ require 'quince_sinatra'
50
+
51
+ class App < Quince::Component
52
+ def render
53
+ Layout(title: "First app") {[
54
+ Counter()
55
+ ]}
56
+ end
57
+ end
58
+
59
+ class Layout < Quince::Component
60
+ Props(title: String)
61
+
62
+ def render
63
+ html(
64
+ head(
65
+ internal_scripts
66
+ ),
67
+ body(
68
+ h1(props.title),
69
+ children
70
+ )
71
+ )
72
+ end
73
+ end
74
+
75
+ class Counter < Quince::Component
76
+ State(val: Integer)
77
+
78
+ def initialize
79
+ @state = State.new(
80
+ val: params.fetch(:val, 0),
81
+ )
82
+ end
83
+
84
+ exposed def increment
85
+ state.val += 1
86
+ end
87
+
88
+ exposed def decrement
89
+ state.val -= 1
90
+ end
91
+
92
+ def render
93
+ div(
94
+ h2("count is #{state.val}"),
95
+ button(onclick: callback(:increment)) { "++" },
96
+ button(onclick: callback(:decrement)) { "--" }
97
+ )
98
+ end
99
+ end
100
+
101
+ expose App, at: "/"
102
+ ```
103
+
104
+ #### See https://github.com/johansenja/quince-demo and https://quince-rb.herokuapp.com/ for more
105
+
106
+ ## Why Quince?
107
+
108
+ ### Why not?
109
+
110
+ - You have pre-existing APIs which you want to integrate a front end with
111
+ - You want to share the back end API with a different service
112
+ - You want more offline functionality
113
+ - You need a super complex/custom front end
114
+
115
+ ### Why?
116
+
117
+ - Lightweight 🪶
118
+ - Very few dependencies
119
+ - Just a couple hundred lines of core logic
120
+ - Fewer than 30 lines (unminified) of JavaScript in the front end
121
+ - Plug and play into multiple back ends 🔌
122
+ - Components > templates 🧩
123
+ - Write html-like elements, but with strong typo resistence
124
+ - no special syntax or compilation required
125
+ - Shallow learning curve if you are already familiar with React 📈
126
+ - Just worry about your core business logic and how the app looks 🧪
127
+ - No need to worry about
128
+ - routes
129
+ - controllers
130
+ - front end -> back end communication/APIs/Data transfer
131
+ - front end -> back end code sharing
132
+ - Quince handles these for you
133
+ - No node_modules 📦
134
+ - No yarn/npm
135
+ - Minimise bundle size concerns
136
+ - Manage your dependencies just using bundler & rubygems
137
+ - Make use of other pre-built Quince components via rubygems
138
+ - Get full use of Ruby's rich and comprehensive standard library 💎
139
+ - Take advantage of Ruby's ability to wrap native libraries (eg. gems using C) ⚡️
140
+ - Fully server-rendered responses 📡
141
+ - Single source of truth for your app's code (no code-sharing needed)
142
+ - Better SEO out the box
143
+ - Know exactly what your user is seeing
144
+ - Tracking a user's activity on the front end has become a big deal, especially in heavily front-end driven apps/SPAs, in order to be able to see how a user is actually using the app (ie. to track how the state has been changing on the front end)
145
+ - This normally requires cookies and a premium third party service
146
+ - But if everything a user sees is generated server-side, it would be easy to reconstruct a user's journey and their state changes
6
147
 
7
148
  ## Installation
8
149
 
9
- Add this line to your application's Gemfile:
150
+ Quince itself is framework agnostic, so you should use an adaptor which plugs it into an existing framework for handling basic server needs
151
+
152
+ ### Install it via adapters
153
+
154
+ - [Sinatra](https://github.com/johansenja/quince_sinatra)
155
+
156
+ Pick one, and add it to your application's Gemfile, eg:
10
157
 
11
158
  ```ruby
12
- gem 'quince'
159
+ gem 'quince_sinatra'
13
160
  ```
14
161
 
15
162
  And then execute:
@@ -18,11 +165,30 @@ And then execute:
18
165
 
19
166
  Or install it yourself as:
20
167
 
21
- $ gem install quince
22
-
23
- ## Usage
24
-
25
- TODO: Write usage instructions here
168
+ $ gem install quince_sinatra
169
+
170
+
171
+ ## Usage notes
172
+
173
+ - All HTML tags are available via a method of the same name, eg. `div()`, `section()`, `span()` - **with the exception of `para` standing in for `p` to avoid clashes with Ruby's common `Kernel#p` method**
174
+ - All HTML attributes are available, and are the same as they would be in a regular html document, eg. `onclick` rather than `onClick` - **with the exception of a `Class`, `Max`, `Min`, `Method`** - which start with capital letters to avoid clashes with some internal methods.
175
+ - Type checking is available at runtime for a component's `State` and `Props`, and is done in accordance with [Typed Struct](https://github.com/johansenja/typed_struct)
176
+ - Children can be specified in one of two places, depending on what you would prefer:
177
+ - as a block argument, to maintain similar readability with real html elements, where attributes come first
178
+ ```ruby
179
+ div(id: :my_div, style: "color: red") { h1("Single child") }
180
+ div(id: "div2", style: "color: green") {[
181
+ h2("multiple"),
182
+ h3("children")
183
+ ]}
184
+ ```
185
+ - as positional arguments (for convenience and cleanliness when no props are passed)
186
+ ```ruby
187
+ div(
188
+ h1("hello world")
189
+ )
190
+ ```
191
+ - A component's `render` method should always return a single top level element, ie. if you wanted to return 2 elements you should wrap them in a `div`
26
192
 
27
193
  ## Development
28
194
 
@@ -32,7 +198,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
32
198
 
33
199
  ## Contributing
34
200
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/quince.
201
+ Bug reports and pull requests are welcome on GitHub at https://github.com/johansenja/quince.
36
202
 
37
203
  ## License
38
204
 
@@ -11,11 +11,12 @@ module Quince
11
11
  t = Quince::Types
12
12
  opt_string_sym = Rbs("#{t::OptionalString} | Symbol")
13
13
  opt_bool = t::OptionalBoolean
14
- opt_method = Rbs("Method | Quince::Types::Undefined")
14
+ opt_callback = Rbs("Quince::Callback::Interface | Quince::Types::Undefined")
15
15
  value = opt_string_sym # for now
16
16
 
17
17
  ATTRIBUTES_BY_ELEMENT = {
18
18
  "A" => {
19
+ # download: t::Any,
19
20
  href: opt_string_sym,
20
21
  hreflang: opt_string_sym,
21
22
  media: opt_string_sym,
@@ -336,7 +337,7 @@ module Quince
336
337
  "Area" => {
337
338
  alt: opt_string_sym,
338
339
  coords: opt_string_sym,
339
- download: t::Any,
340
+ # download: t::Any,
340
341
  href: opt_string_sym,
341
342
  hreflang: opt_string_sym,
342
343
  media: opt_string_sym,
@@ -467,8 +468,14 @@ module Quince
467
468
  }.freeze
468
469
 
469
470
  DOM_EVENTS = {
470
- onclick: opt_method,
471
- onsubmit: opt_method,
471
+ onclick: opt_callback,
472
+ onsubmit: opt_callback,
473
+ onblur: opt_callback,
474
+ onchange: opt_callback,
475
+ onsearch: opt_callback,
476
+ onkeyup: opt_callback,
477
+ onselect: opt_callback,
478
+ onscroll: opt_callback,
472
479
  }.freeze
473
480
  end
474
481
  end
@@ -0,0 +1,30 @@
1
+ const p = this.dataset[`quOn<%= key.to_s[2..-1] %>State`];
2
+ <% if value.debugger %>debugger;<% end %>
3
+ <% if value.if %>
4
+ if (<%= value.if %>) {
5
+ <% end %>
6
+ <% if value.debounce_ms %>
7
+ if (!window[`<%= fn_name %>`]) window[`<%= fn_name %>`] = Q.d((p) => {
8
+ <% end %>
9
+ Q.c(
10
+ `<%= endpoint %>`,
11
+ JSON.stringify(
12
+ {component: p, event: `<%= key.to_s[2..-1] %>`,stateContainer: `<%= state_container %>`,
13
+ <% if value.take_form_values %>
14
+ params: Q.f(this),
15
+ <% end %>
16
+ <% if rerender %>
17
+ rerender: <%= rerender.to_json %>,
18
+ <% end %>}),
19
+ `<%= rerender&.dig(:selector)&.to_s || selector %>`,
20
+ <% if mode = rerender&.dig(:mode) %>`<%= mode.to_s %>`<% end %>
21
+ );
22
+ <% if value.debounce_ms&.positive? %>
23
+ }, <%= value.debounce_ms %>); window[`<%= fn_name %>`](p)
24
+ <% end %>
25
+ <% if value.if %>
26
+ };
27
+ <% end %>
28
+ <% if value.prevent_default %>
29
+ ;return false;
30
+ <% end %>
@@ -0,0 +1,47 @@
1
+ module Quince
2
+ class Callback
3
+ module ComponentHelpers
4
+ protected
5
+
6
+ DEFAULT_CALLBACK_OPTIONS = {
7
+ prevent_default: false,
8
+ take_form_values: false,
9
+ debounce_ms: nil,
10
+ if: nil,
11
+ debugger: false,
12
+ rerender: nil,
13
+ }.freeze
14
+
15
+ def callback(method_name, **opts)
16
+ unless self.class.instance_variable_get(:@exposed_actions).member?(method_name)
17
+ raise "The action you called is not exposed"
18
+ end
19
+
20
+ opts = DEFAULT_CALLBACK_OPTIONS.merge opts
21
+
22
+ Callback.new(self, method_name, **opts)
23
+ end
24
+ end
25
+
26
+ module Interface
27
+ attr_reader(
28
+ :receiver,
29
+ :method_name,
30
+ *ComponentHelpers::DEFAULT_CALLBACK_OPTIONS.keys,
31
+ )
32
+ end
33
+
34
+ include Interface
35
+
36
+ def initialize(
37
+ receiver,
38
+ method_name,
39
+ **opts
40
+ )
41
+ @receiver, @method_name = receiver, method_name
42
+ ComponentHelpers::DEFAULT_CALLBACK_OPTIONS.each_key do |opt|
43
+ instance_variable_set :"@#{opt}", opts.fetch(opt)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,3 +1,5 @@
1
+ require_relative "callback"
2
+
1
3
  module Quince
2
4
  class Component
3
5
  class << self
@@ -8,7 +10,8 @@ module Quince
8
10
  def Props(**kw)
9
11
  self.const_set "Props", TypedStruct.new(
10
12
  { default: Quince::Types::Undefined },
11
- Quince::Component::HTML_SELECTOR_ATTR => String,
13
+ Quince::Component::PARENT_SELECTOR_ATTR => String,
14
+ Quince::Component::SELF_SELECTOR => String,
12
15
  **kw,
13
16
  )
14
17
  end
@@ -21,21 +24,26 @@ module Quince
21
24
  self.const_set "State", st
22
25
  end
23
26
 
24
- def exposed(action, meth0d: :POST)
27
+ def exposed(action, method: :POST)
25
28
  @exposed_actions ||= Set.new
26
29
  @exposed_actions.add action
27
30
  route = "/api/#{self.name}/#{action}"
28
31
  Quince.middleware.create_route_handler(
29
- verb: meth0d,
32
+ verb: method,
30
33
  route: route,
31
34
  ) do |params|
32
- instance = Quince::Serialiser.deserialise params[:component]
35
+ instance = Quince::Serialiser.deserialise(CGI.unescapeHTML(params[:component]))
36
+ Quince::Component.class_variable_set :@@params, params
37
+ render_with = if params[:rerender]
38
+ instance.instance_variable_set :@state_container, params[:stateContainer]
39
+ params[:rerender][:method].to_sym
40
+ else
41
+ :render
42
+ end
43
+ instance.instance_variable_set :@render_with, render_with
44
+ instance.instance_variable_set :@callback_event, params[:event]
33
45
  if @exposed_actions.member? action
34
- if instance.method(action).arity.zero?
35
- instance.send action
36
- else
37
- instance.send action, params[:params]
38
- end
46
+ instance.send action
39
47
  instance
40
48
  else
41
49
  raise "The action you called is not exposed"
@@ -45,27 +53,32 @@ module Quince
45
53
  route
46
54
  end
47
55
 
48
- def initial_state=(attrs)
49
- @initial_state ||= self::State.new(**attrs)
56
+ def create(*children, **props, &block_children)
57
+ allocate.tap do |instance|
58
+ id = SecureRandom.alphanumeric 6
59
+ instance.instance_variable_set :@__id, id
60
+ instance.instance_variable_set :@props, initialize_props(self, id, **props)
61
+ kids = block_children ? block_children.call : children
62
+ instance.instance_variable_set(:@children, kids)
63
+ instance.send :initialize
64
+ end
50
65
  end
51
66
 
52
- attr_reader :initial_state
53
-
54
67
  private
55
68
 
56
69
  def initialize_props(const, id, **props)
57
- const::Props.new(HTML_SELECTOR_ATTR => id, **props) if const.const_defined?("Props")
70
+ if const.const_defined?("Props")
71
+ const::Props.new(PARENT_SELECTOR_ATTR => id, **props, SELF_SELECTOR => id)
72
+ end
58
73
  end
59
74
  end
60
75
 
61
- attr_reader :props, :state, :children
76
+ include Callback::ComponentHelpers
62
77
 
63
- def initialize(*children, **props, &block_children)
64
- @__id = SecureRandom.alphanumeric(6)
65
- @props = self.class.send :initialize_props, self.class, @__id, **props
66
- @state = self.class.initial_state
67
- @children = block_children || children
68
- end
78
+ # set default
79
+ @@params = {}
80
+
81
+ attr_reader :props, :state, :children
69
82
 
70
83
  def render
71
84
  raise "not implemented"
@@ -74,17 +87,26 @@ module Quince
74
87
  protected
75
88
 
76
89
  def to(route, via: :POST)
77
- self.class.exposed route, meth0d: via
90
+ self.class.exposed route, method: via
91
+ end
92
+
93
+ def params
94
+ @@params
78
95
  end
79
96
 
80
97
  private
81
98
 
82
99
  attr_reader :__id
83
100
 
84
- HTML_SELECTOR_ATTR = :"data-respid"
101
+ PARENT_SELECTOR_ATTR = :"data-quid-parent"
102
+ SELF_SELECTOR = :"data-quid"
103
+
104
+ def html_parent_selector
105
+ "[#{PARENT_SELECTOR_ATTR}='#{__id}']".freeze
106
+ end
85
107
 
86
- def html_element_selector
87
- "[#{HTML_SELECTOR_ATTR}='#{__id}']".freeze
108
+ def html_self_selector
109
+ "[#{SELF_SELECTOR}='#{props[SELF_SELECTOR]}']".freeze
88
110
  end
89
111
  end
90
112
  end
@@ -1,9 +1,13 @@
1
1
  require "oj"
2
2
  require_relative "attributes_by_element"
3
3
  require_relative "serialiser"
4
+ require "erb"
4
5
 
5
6
  module Quince
6
7
  module HtmlTagComponents
8
+ CALLBACK_TEMPLATE = File.read(File.join(__dir__, "callback.js.erb")).delete!("\n").freeze
9
+ CALLBACK_ERB_INSTANCE = ERB.new(CALLBACK_TEMPLATE)
10
+
7
11
  def self.define_html_tag_component(const_name, attrs, self_closing: false)
8
12
  klass = Class.new(Quince::Component) do
9
13
  Props(
@@ -17,9 +21,12 @@ module Quince
17
21
  props.each_pair.map { |k, v| to_html_attr(k, v) }.compact.join(" ")
18
22
  end
19
23
  result = "<#{tag_name}"
20
- result << " #{attrs}>" unless attrs.empty?
21
-
22
- return result if self_closing?
24
+ if !attrs.empty?
25
+ result << " #{attrs}>"
26
+ return result if self_closing?
27
+ elsif attrs.empty? && self_closing?
28
+ return result << ">"
29
+ end
23
30
 
24
31
  result << Quince.to_html(children)
25
32
  result << "</#{tag_name}>"
@@ -33,27 +40,22 @@ module Quince
33
40
  attrib = case value
34
41
  when String, Integer, Float, Symbol
35
42
  value.to_s
36
- when Method
37
- owner = value.owner
43
+ when Callback::Interface
38
44
  receiver = value.receiver
39
- name = value.name
40
- selector = receiver.send :html_element_selector
45
+ owner = receiver.class.name
46
+ name = value.method_name
47
+ endpoint = "/api/#{owner}/#{name}"
48
+ selector = receiver.send :html_parent_selector
41
49
  internal = Quince::Serialiser.serialise receiver
42
- payload = { component: internal }.to_json
43
- case key
44
- when :onclick
45
- CGI.escape_html(
46
- "callRemoteEndpoint(`/api/#{owner}/#{name}`,`#{payload}`,`#{selector}`)"
47
- )
48
- when :onsubmit
49
- CGI.escape_html(
50
- "const p = #{payload}; callRemoteEndpoint( `/api/#{owner}/#{name}`, JSON.stringify({...p, params: getFormValues(this)}), `#{selector}`); return false"
51
- )
52
- end
50
+ fn_name = "_Q_#{key}_#{receiver.send(:__id)}"
51
+ rerender = value.rerender
52
+ state_container = html_self_selector
53
+ code = CALLBACK_ERB_INSTANCE.result(binding)
54
+ return %Q{#{key}="#{CGI.escape_html(code)}" data-qu-#{key}-state="#{CGI.escapeHTML(internal)}"}
53
55
  when true
54
56
  return key
55
57
  when false, nil, Quince::Types::Undefined
56
- return ""
58
+ return nil
57
59
  else
58
60
  raise "prop type not yet implemented #{value}"
59
61
  end
@@ -2,98 +2,11 @@ module Quince
2
2
  class Serialiser
3
3
  class << self
4
4
  def serialise(obj)
5
- val = case obj
6
- when Quince::Component
7
- {
8
- id: serialise(obj.send(:__id)),
9
- props: serialise(obj.props),
10
- state: serialise(obj.state),
11
- children: serialise(obj.children),
12
- html_element_selector: serialise(obj.send(:html_element_selector)),
13
- }
14
- when Array
15
- obj.map { |e| serialise e }
16
- when TypedStruct, Struct, OpenStruct, Hash
17
- result = obj.each_pair.each_with_object({}) do |(k, v), ob|
18
- case v
19
- when Undefined
20
- next
21
- else
22
- ob[k] = serialise(v)
23
- end
24
- end
25
- if result.empty? && !obj.is_a?(Hash)
26
- obj = nil
27
- nil
28
- else
29
- result
30
- end
31
- when Proc
32
- obj = obj.call # is there a more efficient way of doing this?
33
- serialise(obj)
34
- when String
35
- res = obj.gsub "\n", '\n'
36
- res.gsub! ?", '\"'
37
- res
38
- else
39
- obj
40
- end
41
-
42
- { t: obj.class&.name, v: val }
5
+ Oj.dump(obj)
43
6
  end
44
7
 
45
8
  def deserialise(json)
46
- case json[:t]
47
- when "String", "Integer", "Float", "NilClass", "TrueClass", "FalseClass"
48
- json[:v]
49
- when "Symbol"
50
- json[:v].to_sym
51
- when "Array"
52
- json[:v].map { |e| deserialise e }
53
- when "Hash"
54
- transform_hash json[:v]
55
- when "OpenStruct"
56
- OpenStruct.new(**transform_hash(props))
57
- when nil
58
- nil
59
- else
60
- klass = Object.const_get(json[:t])
61
- if klass < TypedStruct
62
- transform_hash_for_struct(json[:v]) || {}
63
- elsif klass < Quince::Component
64
- instance = klass.allocate
65
- val = json[:v]
66
- id = deserialise val[:id]
67
-
68
- instance.instance_variable_set :@__id, id
69
- instance.instance_variable_set(
70
- :@props,
71
- klass.send(
72
- :initialize_props,
73
- klass,
74
- id,
75
- **(deserialise(val[:props]) || {}),
76
- ),
77
- )
78
- st = deserialise(val[:state])
79
- instance.instance_variable_set :@state, klass::State.new(**st) if st
80
- instance.instance_variable_set :@children, deserialise(val[:children])
81
- instance
82
- else
83
- klass = Object.const_get(json[:t])
84
- klass.new(deserialise(json[:v]))
85
- end
86
- end
87
- end
88
-
89
- private
90
-
91
- def transform_hash(hsh)
92
- hsh.transform_values! { |v| deserialise v }
93
- end
94
-
95
- def transform_hash_for_struct(hsh)
96
- hsh.to_h { |k, v| [k.to_sym, deserialise(v)] }
9
+ Oj.load(json)
97
10
  end
98
11
  end
99
12
  end
@@ -10,21 +10,24 @@ module Quince
10
10
  def middleware=(middleware)
11
11
  @middleware = middleware
12
12
  Object.define_method(:expose) do |component, at:|
13
- component = component.new if component.instance_of? Class
14
-
15
13
  Quince.middleware.create_route_handler(
16
14
  verb: :GET,
17
15
  route: at,
18
- component: component,
19
- )
16
+ ) do |params|
17
+ component = component.create if component.instance_of? Class
18
+ Quince::Component.class_variable_set :@@params, params
19
+ component.instance_variable_set :@render_with, :render
20
+ component
21
+ end
20
22
  end
23
+ Object.send :private, :expose
21
24
  end
22
25
 
23
26
  def define_constructor(const, constructor_name = const.to_s)
24
27
  HtmlTagComponents.instance_eval do
25
28
  define_method(constructor_name) do |*children, **props, &block_children|
26
- new_props = { **props, Quince::Component::HTML_SELECTOR_ATTR => __id }
27
- const.new(*children, **new_props, &block_children)
29
+ new_props = { **props, Quince::Component::PARENT_SELECTOR_ATTR => __id }
30
+ const.create(*children, **new_props, &block_children)
28
31
  end
29
32
  end
30
33
  end
@@ -42,12 +45,30 @@ module Quince
42
45
  output = to_html(output.call)
43
46
  when NilClass
44
47
  output = ""
45
- else
48
+ when Component
46
49
  tmp = output
47
- output = output.render
48
- if output.is_a?(Array)
49
- raise "#render in #{tmp.class} should not return multiple elements. Consider wrapping it in a div"
50
+ render_with = output.instance_variable_get(:@render_with) || :render
51
+ output = output.send render_with
52
+ case render_with
53
+ when :render
54
+ if output.is_a?(Array)
55
+ raise "#render in #{tmp.class} should not return multiple elements. Consider wrapping it in a div"
56
+ end
57
+ else
58
+ internal = Quince::Serialiser.serialise tmp
59
+ updated_state = CGI.escapeHTML(internal).to_json
60
+ selector = tmp.instance_variable_get :@state_container
61
+ event = tmp.instance_variable_get :@callback_event
62
+
63
+ scr = to_html(HtmlTagComponents::Script.create(<<~JS, type: "text/javascript"))
64
+ var stateContainer = document.querySelector(`#{selector}`);
65
+ stateContainer.dataset.quOn#{event}State = #{updated_state};
66
+ JS
67
+
68
+ output += (output.is_a?(String) ? scr : [scr])
50
69
  end
70
+ else
71
+ raise "don't know how to render #{output.class} (#{output.inspect})"
51
72
  end
52
73
  end
53
74
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quince
4
- VERSION = "0.1.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/scripts.js CHANGED
@@ -1,24 +1,75 @@
1
- const callRemoteEndpoint = (endpoint, payload, selector) => {
2
- fetch(
3
- endpoint,
4
- {
5
- method: `POST`,
6
- headers: {
7
- "Content-Type": `application/json;charset=utf-8`
8
- },
9
- body: payload,
10
- }
11
- ).then(resp => resp.text()).then(html => {
12
- const element = document.querySelector(selector);
13
- if (!element) {
14
- throw `element not found for ${selector}`;
15
- }
1
+ const Q = {
2
+ c: (endpoint, payload, selector, mode = "replace") => {
3
+ return fetch(
4
+ endpoint,
5
+ {
6
+ method: `POST`,
7
+ headers: {
8
+ "Content-Type": `application/json;charset=utf-8`
9
+ },
10
+ body: payload,
11
+ }
12
+ ).then(resp => resp.text()).then(html => {
13
+ const element = document.querySelector(selector);
14
+ if (!element) {
15
+ throw `element not found for ${selector}`;
16
+ }
16
17
 
17
- element.outerHTML = html
18
- })
19
- }
18
+ switch (mode) {
19
+ case "append_diff":
20
+ const tmpElem = document.createElement(element.nodeName);
21
+ tmpElem.innerHTML = html;
22
+ const newNodes = Array.from(tmpElem.childNodes);
23
+ const script = newNodes.pop();
24
+ const existingChildren = element.childNodes;
25
+ // This comparison doesn't currently work because of each node's unique id (data-quid).
26
+ // maybe it would be possible to use regex replace to on the raw html, but it could also
27
+ // be overkill
28
+ // let c = 0;
29
+ // for (; c < existingChildren.length; c++) {
30
+ // if (existingChildren[c].isEqualNode(newNodes[c]))
31
+ // continue;
32
+ // else
33
+ // break;
34
+ // }
35
+ // for the time being, we can just assume that we can just take the extra items
36
+ let c = existingChildren.length;
37
+ for (const node of newNodes.slice(c)) {
38
+ element.appendChild(node);
39
+ }
40
+
41
+ const newScript = document.createElement("script");
42
+ newScript.dataset.quid = script.dataset.quid;
43
+ newScript.innerHTML = script.innerHTML;
44
+ document.head.appendChild(newScript);
45
+ break;
46
+ case "replace":
47
+ element.outerHTML = html;
48
+ break;
49
+ default:
50
+ throw `mode ${mode} is not valid`;
51
+ }
52
+ })
53
+ },
54
+ f: (elem) => {
55
+ let form = elem.localName === "form" ? elem : elem.form;
56
+ if (!form) {
57
+ throw `element ${elem} should belong to a form`;
58
+ }
59
+ const fd = new FormData(form);
60
+ return Object.fromEntries(fd.entries());
61
+ },
62
+ d: (func, wait_ms) => {
63
+ let timer = null;
20
64
 
21
- const getFormValues = (form) => {
22
- const fd = new FormData(form);
23
- return Object.fromEntries(fd.entries());
24
- }
65
+ return (...args) => {
66
+ clearTimeout(timer);
67
+ return new Promise((resolve) => {
68
+ timer = setTimeout(
69
+ () => resolve(func(...args)),
70
+ wait_ms,
71
+ );
72
+ });
73
+ };
74
+ },
75
+ };
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quince
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Johansen
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-09-09 00:00:00.000000000 Z
11
+ date: 2021-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: typed_struct
@@ -57,6 +57,8 @@ files:
57
57
  - bin/setup
58
58
  - lib/quince.rb
59
59
  - lib/quince/attributes_by_element.rb
60
+ - lib/quince/callback.js.erb
61
+ - lib/quince/callback.rb
60
62
  - lib/quince/component.rb
61
63
  - lib/quince/html_tag_components.rb
62
64
  - lib/quince/serialiser.rb