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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +176 -10
- data/lib/quince/attributes_by_element.rb +11 -4
- data/lib/quince/callback.js.erb +30 -0
- data/lib/quince/callback.rb +47 -0
- data/lib/quince/component.rb +47 -25
- data/lib/quince/html_tag_components.rb +21 -19
- data/lib/quince/serialiser.rb +2 -89
- data/lib/quince/singleton_methods.rb +31 -10
- data/lib/quince/version.rb +1 -1
- data/scripts.js +73 -22
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '0039bd9ca9658cfebba4048cc84fdbb095d8f2e656930501528f420c4dfa3bba'
|
4
|
+
data.tar.gz: a9720e7b11db54113fd56f1e09e43c980893297bbd5d3f46b1cdd0fc8ea2b61e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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.
|
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
|
-
|
3
|
+
### What is Quince?
|
4
4
|
|
5
|
-
|
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
|
-
|
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 '
|
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
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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/
|
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
|
-
|
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:
|
471
|
-
onsubmit:
|
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
|
data/lib/quince/component.rb
CHANGED
@@ -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::
|
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,
|
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:
|
32
|
+
verb: method,
|
30
33
|
route: route,
|
31
34
|
) do |params|
|
32
|
-
instance = Quince::Serialiser.deserialise
|
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
|
-
|
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
|
49
|
-
|
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
|
-
|
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
|
-
|
76
|
+
include Callback::ComponentHelpers
|
62
77
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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,
|
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
|
-
|
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
|
87
|
-
"[#{
|
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
|
-
|
21
|
-
|
22
|
-
|
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
|
37
|
-
owner = value.owner
|
43
|
+
when Callback::Interface
|
38
44
|
receiver = value.receiver
|
39
|
-
|
40
|
-
|
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
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
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
|
data/lib/quince/serialiser.rb
CHANGED
@@ -2,98 +2,11 @@ module Quince
|
|
2
2
|
class Serialiser
|
3
3
|
class << self
|
4
4
|
def serialise(obj)
|
5
|
-
|
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
|
-
|
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
|
-
|
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::
|
27
|
-
const.
|
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
|
-
|
48
|
+
when Component
|
46
49
|
tmp = output
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
|
data/lib/quince/version.rb
CHANGED
data/scripts.js
CHANGED
@@ -1,24 +1,75 @@
|
|
1
|
-
const
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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.
|
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-
|
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
|