turbo_reflex 0.0.2 → 0.0.4

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: b69b530a4e88be7ec81b38f0076f6d8181eb4a8e209bb2ecf8d443bbd4ab3b2f
4
- data.tar.gz: d71d737c155f19c32170280ccb8bf25c9dc55c4e60698f7f19fbeff19ee80225
3
+ metadata.gz: c9fe08630cc0c938fc6b2eb4ed55b841bd968d3b1a9e47c4d7831eba6877994f
4
+ data.tar.gz: 41e2891f690ec5d77c0c3ec537145f4126e750351bcd9b9040fa701374a35afa
5
5
  SHA512:
6
- metadata.gz: 48a856e206b2cbbb1692829c0157e6c5429f5cef111f64e44ccdff91247cee773b4c91e12219e2df1bccb6fde44eaed6817c3ac1a55beaf64144b303acf26bdf
7
- data.tar.gz: b11f7bc4ffe62ea4dc9fd0f79bc1952bf1316973a3e109c0e40aca4e56ee9efc81d5f857f18e2c84fe0e9e7cfdaec519231145d4b65637fbaf92809be13386f6
6
+ metadata.gz: f2069894ceef06beac578f28a4c4729ab272e089fbe4556f71743ef2d7dfdd328512102ac24d0cf4d8d1f5379fc67e2c356e1ec05b74a207ddf656ac470d7bdf
7
+ data.tar.gz: f316ed002e6560cd93d4d30c600c51d5181c82a500e75b93baad27195d0934fd5ebf4fb0ea1cdecd72fd7b5d6654f4671c104d9d46c30e446c77df876a7f9911
data/Dockerfile ADDED
@@ -0,0 +1,43 @@
1
+ FROM ruby:3.1.2
2
+
3
+ RUN apt-get -y update && \
4
+ apt-get -y upgrade && \
5
+ apt-get -y --allow-downgrades --allow-remove-essential --allow-change-held-packages install \
6
+ build-essential \
7
+ git \
8
+ nodejs \
9
+ npm \
10
+ sqlite3 \
11
+ tzdata && \
12
+ apt-get clean
13
+
14
+ # setup ruby gems
15
+ RUN gem update --system && \
16
+ gem install bundler && \
17
+ bundle config set --global --without test
18
+
19
+ # setup yarn
20
+ RUN npm install -g yarn
21
+
22
+ # get application code
23
+ RUN rm -rf /opt/turbo_reflex
24
+ RUN git clone --origin github --branch main --depth 1 https://github.com/hopsoft/turbo_reflex.git /opt/turbo_reflex
25
+
26
+ # install application dependencies 1st time
27
+ WORKDIR /opt/turbo_reflex
28
+ RUN yarn
29
+ RUN bundle
30
+
31
+ # prepare the environment
32
+ ENV RAILS_ENV=production RAILS_LOG_TO_STDOUT=true RAILS_SERVE_STATIC_FILES=true
33
+
34
+ # prepare and run the application
35
+ CMD git pull --no-rebase github main && \
36
+ yarn && \
37
+ cd test/dummy && \
38
+ bundle && \
39
+ rm -f tmp/pids/server.pid && \
40
+ bin/rails db:create db:migrate && \
41
+ bin/rails assets:clobber && \
42
+ bin/rails assets:precompile && \
43
+ bin/rails s --binding=0.0.0.0 --port=3000
data/Dockerfile.orig ADDED
@@ -0,0 +1,49 @@
1
+ FROM ruby:3.1.2
2
+
3
+ RUN apt-get -y update && \
4
+ apt-get -y upgrade && \
5
+ apt-get -y --allow-downgrades --allow-remove-essential --allow-change-held-packages install \
6
+ build-essential \
7
+ git \
8
+ nodejs \
9
+ npm \
10
+ sqlite3 \
11
+ tzdata && \
12
+ apt-get clean
13
+
14
+ <<<<<<< HEAD
15
+ # prepare the environment
16
+ ENV RAILS_ENV=production RAILS_LOG_TO_STDOUT=true RAILS_SERVE_STATIC_FILES=true
17
+
18
+ =======
19
+ >>>>>>> main
20
+ # setup ruby gems
21
+ RUN gem update --system && \
22
+ gem install bundler && \
23
+ bundle config set --global --without test
24
+
25
+ # setup yarn
26
+ RUN npm install -g yarn
27
+
28
+ # get application code
29
+ RUN git clone --origin github --branch main --depth 1 https://github.com/hopsoft/turbo_reflex.git /opt/turbo_reflex
30
+
31
+ # install application dependencies 1st time
32
+ WORKDIR /opt/turbo_reflex
33
+ RUN yarn
34
+ WORKDIR /opt/turbo_reflex/test/dummy
35
+ RUN bundle
36
+
37
+ # prepare the environment
38
+ ENV RAILS_ENV=production RAILS_LOG_TO_STDOUT=true RAILS_SERVE_STATIC_FILES=true
39
+
40
+ # prepare and run the application
41
+ CMD git pull --no-rebase github && \
42
+ git checkout hopsoft/hijack && \
43
+ cd /opt/turbo_reflex && yarn && \
44
+ cd /opt/turbo_reflex/test/dummy && bundle && \
45
+ rm -f tmp/pids/server.pid && \
46
+ bin/rails db:create db:migrate && \
47
+ bin/rails assets:clobber && \
48
+ bin/rails assets:precompile && \
49
+ bin/rails s --binding=0.0.0.0 --port=3000
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- turbo_reflex (0.0.2)
4
+ turbo_reflex (0.0.4)
5
5
  rails (>= 6.1)
6
6
  turbo-rails (>= 1.1)
7
7
 
@@ -77,6 +77,7 @@ GEM
77
77
  public_suffix (>= 2.0.2, < 6.0)
78
78
  ansi (1.5.0)
79
79
  ast (2.4.2)
80
+ bindex (0.8.1)
80
81
  builder (3.2.4)
81
82
  byebug (11.1.3)
82
83
  capybara (3.37.1)
@@ -103,6 +104,7 @@ GEM
103
104
  cliver (~> 0.3)
104
105
  concurrent-ruby (~> 1.1)
105
106
  websocket-driver (>= 0.6, < 0.8)
107
+ foreman (0.87.2)
106
108
  globalid (1.0.0)
107
109
  activesupport (>= 5.0)
108
110
  i18n (1.12.0)
@@ -111,7 +113,7 @@ GEM
111
113
  actionpack (>= 6.0.0)
112
114
  railties (>= 6.0.0)
113
115
  json (2.6.2)
114
- loofah (2.18.0)
116
+ loofah (2.19.0)
115
117
  crass (~> 1.0.2)
116
118
  nokogiri (>= 1.5.9)
117
119
  magic_frozen_string_literal (1.2.0)
@@ -142,6 +144,8 @@ GEM
142
144
  net-protocol
143
145
  timeout
144
146
  nio4r (2.5.8)
147
+ nokogiri (1.13.8-aarch64-linux)
148
+ racc (~> 1.4)
145
149
  nokogiri (1.13.8-arm64-darwin)
146
150
  racc (~> 1.4)
147
151
  parallel (1.22.1)
@@ -190,6 +194,7 @@ GEM
190
194
  rake (13.0.6)
191
195
  regexp_parser (2.5.0)
192
196
  rexml (3.2.5)
197
+ rouge (4.0.0)
193
198
  rubocop (1.35.1)
194
199
  json (~> 2.3)
195
200
  parallel (~> 1.10)
@@ -219,6 +224,7 @@ GEM
219
224
  actionpack (>= 5.2)
220
225
  activesupport (>= 5.2)
221
226
  sprockets (>= 3.0.0)
227
+ sqlite3 (1.5.0-aarch64-linux)
222
228
  sqlite3 (1.5.0-arm64-darwin)
223
229
  standard (1.16.1)
224
230
  rubocop (= 1.35.1)
@@ -226,19 +232,28 @@ GEM
226
232
  standardrb (1.0.1)
227
233
  standard
228
234
  strscan (3.0.4)
235
+ tailwindcss-rails (2.0.14-aarch64-linux)
236
+ railties (>= 6.0.0)
237
+ tailwindcss-rails (2.0.14-arm64-darwin)
238
+ railties (>= 6.0.0)
229
239
  thor (1.2.1)
230
240
  timeout (0.3.0)
231
241
  turbo-rails (1.1.1)
232
242
  actionpack (>= 6.0.0)
233
243
  activejob (>= 6.0.0)
234
244
  railties (>= 6.0.0)
235
- turbo_ready (0.0.7)
245
+ turbo_ready (0.1.0)
236
246
  rails (>= 6.1)
237
247
  turbo-rails (>= 1.1)
238
248
  tzinfo (2.0.5)
239
249
  concurrent-ruby (~> 1.0)
240
- unicode-display_width (2.2.0)
241
- webdrivers (5.0.0)
250
+ unicode-display_width (2.3.0)
251
+ web-console (4.2.0)
252
+ actionview (>= 6.0.0)
253
+ activemodel (>= 6.0.0)
254
+ bindex (>= 0.4.0)
255
+ railties (>= 6.0.0)
256
+ webdrivers (5.1.0)
242
257
  nokogiri (~> 1.6)
243
258
  rubyzip (>= 1.3.0)
244
259
  selenium-webdriver (~> 4.0)
@@ -251,11 +266,13 @@ GEM
251
266
  zeitwerk (2.6.0)
252
267
 
253
268
  PLATFORMS
269
+ aarch64-linux
254
270
  arm64-darwin-21
255
271
 
256
272
  DEPENDENCIES
257
273
  capybara
258
274
  cuprite
275
+ foreman
259
276
  importmap-rails
260
277
  magic_frozen_string_literal
261
278
  minitest-reporters
@@ -264,11 +281,14 @@ DEPENDENCIES
264
281
  puma
265
282
  rake
266
283
  rexml
284
+ rouge
267
285
  sprockets-rails
268
286
  sqlite3
269
287
  standardrb
288
+ tailwindcss-rails
270
289
  turbo_ready
271
290
  turbo_reflex!
291
+ web-console
272
292
  webdrivers
273
293
 
274
294
  BUNDLED WITH
data/README.md CHANGED
@@ -117,14 +117,14 @@ TurboReflex is a lightweight Turbo Frame extension... which means that reactivit
117
117
 
118
118
  ```diff
119
119
  # Gemfile
120
- +gem "turbo_reflex", "~> 0.0.2"
120
+ +gem "turbo_reflex", "~> 0.0.3"
121
121
  ```
122
122
 
123
123
  ```diff
124
124
  # package.json
125
125
  "dependencies": {
126
126
  "@hotwired/turbo-rails": ">=7.1",
127
- + "turbo_reflex": "^0.0.2"
127
+ + "turbo_reflex": "^0.0.3"
128
128
  ```
129
129
 
130
130
  *Be sure to install the __same version__ of the Ruby and JavaScript libraries.*
@@ -142,16 +142,12 @@ TurboReflex is a lightweight Turbo Frame extension... which means that reactivit
142
142
  # app/views/layouts/application.html.erb
143
143
  <html>
144
144
  <head>
145
- ...
146
- + <%= turbo_reflex_meta_tag %>
147
- ...
148
- ```
149
-
150
- ```diff
151
- # /app/controllers/application_controller.rb
152
- class ApplicationController < ActionController::Base
153
- + include TurboReflex::Controller
154
- end
145
+ + <%= turbo_reflex_meta_tag %>
146
+ </head>
147
+ <body>
148
+ + <%= turbo_reflex_frame_tag %>
149
+ </body>
150
+ </html>
155
151
  ```
156
152
 
157
153
  ## Usage
@@ -163,7 +159,7 @@ This example illustrates how to use TurboReflex to manage upvotes on a Post.
163
159
  ```erb
164
160
  <!-- app/views/posts/show.html.erb -->
165
161
  <%= turbo_frame_tag dom_id(@post) do %>
166
- <a href="#" data-turbo-reflex="VotesReflex#upvote">Upvote</a>
162
+ <a href="#" data-turbo-reflex="PostReflex#upvote">Upvote</a>
167
163
  Upvote Count: <%= @post.votes >
168
164
  <% end %>
169
165
  ```
@@ -172,7 +168,7 @@ This example illustrates how to use TurboReflex to manage upvotes on a Post.
172
168
 
173
169
  ```ruby
174
170
  # app/reflexes/posts_reflex.rb
175
- class PostsReflex < TurboReflex::Base
171
+ class PostReflex < TurboReflex::Base
176
172
  def upvote
177
173
  Post.find(controller.params[:id]).increment! :votes
178
174
  end
@@ -221,18 +217,10 @@ TurboReflex supports the following lifecycle events.
221
217
 
222
218
  ### Targeting Frames
223
219
 
224
- By default TurboReflex targets the [`closest`](https://developer.mozilla.org/en-US/docs/Web/API/Element/closest) `<turbo-frame>` element,
225
- but you can also explicitly target other frames.
226
-
227
- 1. Look for `data-turbo-reflex-frame` on the reflex elemnt
228
-
229
- ```erb
230
- <input type="checkbox"
231
- data-turbo-reflex="ExampleReflex#work"
232
- data-turbo-reflex-frame="some-frame-id">
233
- ```
220
+ TurboReflex targets the [`closest`](https://developer.mozilla.org/en-US/docs/Web/API/Element/closest) `<turbo-frame>` element by default,
221
+ but you can also explicitly target other frames just like you normally would with Turbo Frames.
234
222
 
235
- 2. Look for `data-turbo-frame` on the reflex element
223
+ 1. Look for `data-turbo-frame` on the reflex element
236
224
 
237
225
  ```erb
238
226
  <input type="checkbox"
@@ -240,7 +228,7 @@ but you can also explicitly target other frames.
240
228
  data-turbo-frame="some-frame-id">
241
229
  ```
242
230
 
243
- 3. Find the closest `<turbo-frame>` to the reflex element
231
+ 1. Find the closest `<turbo-frame>` to the reflex element
244
232
 
245
233
  ```erb
246
234
  <turbo-frame id="example-frame">
@@ -442,6 +430,8 @@ The best way to learn this stuff is from working examples.
442
430
  Be sure to clone the library and run the test application.
443
431
  Then dig into the internals.
444
432
 
433
+ #### Running Locally
434
+
445
435
  ```sh
446
436
  git clone https://github.com/hopsoft/turbo_reflex.git
447
437
  cd turbo_reflex
@@ -451,6 +441,17 @@ bin/rails s
451
441
  # View the app in a browser at http://localhost:3000
452
442
  ```
453
443
 
444
+ #### Running in Docker
445
+
446
+ Docker users can get up and running even faster.
447
+
448
+ ```sh
449
+ git clone https://github.com/hopsoft/turbo_reflex.git
450
+ cd turbo_reflex
451
+ docker compose up -d
452
+ # View the app in a browser at http://localhost:3000
453
+ ```
454
+
454
455
  You can review the implementation in [`test/dummy/app`](https://github.com/hopsoft/turbo_reflex/tree/main/test/dummy).
455
456
  *Feel free to add some demos and submit a pull request while you're in there.*
456
457
 
@@ -462,13 +463,14 @@ The gem is available as open source under the terms of the [MIT License](https:/
462
463
 
463
464
  ## Todos
464
465
 
466
+ - [ ] Consider falling back to the turbo-reflex-frame when a frame can't be identified
467
+ - [ ] Consider how to best support `link_to` with methods other than GET
468
+ - [ ] Update system tests for new demos
465
469
  - [ ] Add tests for lifecycle events
466
470
  - [ ] Add tests for select elements
467
471
  - [ ] Add tests for checkbox elements
468
- - [ ] Add controller tests
469
472
  - [ ] Add tests for all variants of frame targeting
470
473
 
471
-
472
474
  ## Releasing
473
475
 
474
476
  1. Run `yarn upgrade` and `bundle update` to pick up the latest
@@ -0,0 +1,2 @@
1
+ var l={beforeStart:"turbo-reflex:before-start",start:"turbo-reflex:start",finish:"turbo-reflex:finish",error:"turbo-reflex:error",missingFrameId:"turbo-reflex:missing-frame-id",missingFrame:"turbo-reflex:missing-frame",missingFrameSrc:"turbo-reflex:missing-frame-src"};function y(t,e=document,r={}){let n=new CustomEvent(t,{detail:r,cancelable:!0,bubbles:!0});e.dispatchEvent(n)}function L(){Object.values(l).forEach(t=>console.log(t))}var o={...l,dispatch:y,logEventNames:L};var c={};addEventListener("turbo:before-fetch-response",t=>{let e=t.target;c[e.id]=e.src;let{turboReflexActive:r,turboReflexElementId:n}=e.dataset;if(!r)return;let s=document.getElementById(n);delete e.dataset.turboReflexActive,delete e.dataset.turboReflexElementId,o.dispatch(o.finish,s||document,{frame:e,element:s||"Unknown! Missing id attribute."})});addEventListener("turbo:frame-load",t=>{let e=t.target;e.dataset.turboReflexSrc=c[e.id]||e.src||e.dataset.turboReflexSrc,delete c[e.id]});var S={get token(){return document.getElementById("turbo-reflex-token").getAttribute("content")}},f=S;function m(t){return t.closest("[data-turbo-reflex]")}function F(t){return t.closest("turbo-frame")}function b(t){let e=t.dataset.turboFrame;if(!e){let r=F(t);r&&(e=r.id)}return e||(console.error("The reflex element does not specify a frame!","Please move the reflex element inside a <turbo-frame> or set the 'data-turbo-frame' attribute.",t),o.dispatch(o.missingFrameId,t,{element:t})),e}function g(t){let e=document.getElementById(t);return e||(console.error(`The frame '${t}' does not exist!`),o.dispatch(o.missingFrame,document,{id:t})),e}function x(t){let e=t.dataset.turboReflexSrc||t.src;return e||(console.error(`The the 'src' for <turbo-frame id='${t.id}'> is unknown!`,"TurboReflex uses 'src' to (re)render frame content after the reflex is invoked.","Please set the 'src' or 'data-turbo-reflex-src' attribute on the <turbo-frame> element.",t),o.dispatch(o.missingFrameSrc,t,{frame:t})),e}function k(t,e={}){if(t.tagName.toLowerCase()!=="select")return e.value=t.value;if(!t.multiple)return e.value=t.options[t.selectedIndex].value;e.values=Array.from(t.options).reduce((r,n)=>(n.selected&&r.push(n.value),r),[])}function v(t){let e=Array.from(t.attributes).reduce((r,n)=>(r[n.name]=n.value,r),{});return e.tag=t.tagName,e.checked=t.checked,e.disabled=t.disabled,k(t,e),e}var a={},h;function p(t){h=t}function u(t,e){a[t]=e,document.addEventListener(t,h,!0)}function E(t,e){return e=e.toLowerCase(),a[t].includes(e)||!Object.values(a).flat().includes(e)&&a[t].includes("*")}function R(){console.log(a)}addEventListener("turbo:before-fetch-request",t=>{let e=t.target,{turboReflexActive:r}=e.dataset;if(!r)return;let{fetchOptions:n}=t.detail;n.headers["Turbo-Reflex"]=f.token});function I(t){let e=document.createElement("a");return e.href=t,new URL(e)}function A(t,e={}){e.token=f.token;let r=document.createElement("input");r.type="hidden",r.name="turbo_reflex",r.value=JSON.stringify(e),t.appendChild(r)}function C(t){let e,r,n,s;try{if(e=m(t.target),!e||!E(t.type,e.tagName)||(o.dispatch(o.beforeStart,e,{element:e}),r=b(e),!r)||(n=g(r),!n)||(s=x(n),!s))return;let i={frameId:r,element:v(e)};if(o.dispatch(o.start,e,{element:e,frameId:r,frame:n,frameSrc:s,payload:i}),n.dataset.turboReflexActive=!0,n.dataset.turboReflexElementId=e.id,e.tagName.toLowerCase()==="form")return A(e,i);t.preventDefault();let d=I(s);d.searchParams.set("turbo_reflex",JSON.stringify(i)),n.src=d.toString()}catch(i){console.error("TurboReflex encountered an unexpected error!",{element:e,frameId:r,frame:n,frameSrc:s,target:t.target},i),o.dispatch(o.error,e||document,{element:e,frameId:r,frame:n,frameSrc:s,error:i})}}p(C);u("change",["input","select","textarea"]);u("submit",["form"]);u("click",["*"]);var M={registerEvent:u,logRegisteredEvents:R,logLifecycleEventNames:o.logEventNames};export{M as default};
2
+ //# sourceMappingURL=turbo_reflex.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../javascript/lifecycle_events.js", "../../javascript/frame_sources.js", "../../javascript/security.js", "../../javascript/elements.js", "../../javascript/event_registry.js", "../../javascript/turbo_reflex.js"],
4
+ "sourcesContent": ["const events = {\n beforeStart: 'turbo-reflex:before-start',\n start: 'turbo-reflex:start',\n finish: 'turbo-reflex:finish',\n error: 'turbo-reflex:error',\n missingFrameId: 'turbo-reflex:missing-frame-id',\n missingFrame: 'turbo-reflex:missing-frame',\n missingFrameSrc: 'turbo-reflex:missing-frame-src'\n}\n\nfunction dispatch (name, target = document, detail = {}) {\n const event = new CustomEvent(name, {\n detail,\n cancelable: true,\n bubbles: true\n })\n target.dispatchEvent(event)\n}\n\nfunction logEventNames () {\n Object.values(events).forEach(name => console.log(name))\n}\n\nexport default { ...events, dispatch, logEventNames }\n", "import LifecycleEvents from './lifecycle_events'\nconst frameSources = {}\n\n// fires after receiving a turbo HTTP response\naddEventListener('turbo:before-fetch-response', event => {\n const frame = event.target\n frameSources[frame.id] = frame.src\n\n const { turboReflexActive, turboReflexElementId } = frame.dataset\n if (!turboReflexActive) return\n\n const element = document.getElementById(turboReflexElementId)\n delete frame.dataset.turboReflexActive\n delete frame.dataset.turboReflexElementId\n\n LifecycleEvents.dispatch(LifecycleEvents.finish, element || document, {\n frame,\n element: element || 'Unknown! Missing id attribute.'\n })\n})\n\n// fires when a frame element is navigated and finishes loading\naddEventListener('turbo:frame-load', event => {\n const frame = event.target\n frame.dataset.turboReflexSrc =\n frameSources[frame.id] || frame.src || frame.dataset.turboReflexSrc\n delete frameSources[frame.id]\n})\n", "const Security = {\n get token () {\n return document.getElementById('turbo-reflex-token').getAttribute('content')\n }\n}\n\nexport default Security\n", "import LifecycleEvents from './lifecycle_events'\n\nfunction findClosestReflex (element) {\n return element.closest('[data-turbo-reflex]')\n}\n\nfunction findClosestFrame (element) {\n return element.closest('turbo-frame')\n}\n\nfunction findFrameId (element) {\n let id = element.dataset.turboFrame\n if (!id) {\n const frame = findClosestFrame(element)\n if (frame) id = frame.id\n }\n if (!id) {\n console.error(\n `The reflex element does not specify a frame!`,\n `Please move the reflex element inside a <turbo-frame> or set the 'data-turbo-frame' attribute.`,\n element\n )\n LifecycleEvents.dispatch(LifecycleEvents.missingFrameId, element, {\n element\n })\n }\n return id\n}\n\nfunction findFrame (id) {\n const frame = document.getElementById(id)\n if (!frame) {\n console.error(`The frame '${id}' does not exist!`)\n LifecycleEvents.dispatch(LifecycleEvents.missingFrame, document, { id })\n }\n return frame\n}\n\nfunction findFrameSrc (frame) {\n const frameSrc = frame.dataset.turboReflexSrc || frame.src\n if (!frameSrc) {\n console.error(\n `The the 'src' for <turbo-frame id='${frame.id}'> is unknown!`,\n `TurboReflex uses 'src' to (re)render frame content after the reflex is invoked.`,\n `Please set the 'src' or 'data-turbo-reflex-src' attribute on the <turbo-frame> element.`,\n frame\n )\n LifecycleEvents.dispatch(LifecycleEvents.missingFrameSrc, frame, { frame })\n }\n return frameSrc\n}\n\nfunction assignElementValueToPayload (element, payload = {}) {\n if (element.tagName.toLowerCase() !== 'select')\n return (payload.value = element.value)\n\n if (!element.multiple)\n return (payload.value = element.options[element.selectedIndex].value)\n\n payload.values = Array.from(element.options).reduce((memo, option) => {\n if (option.selected) memo.push(option.value)\n return memo\n }, [])\n}\n\nfunction buildAttributePayload (element) {\n const payload = Array.from(element.attributes).reduce((memo, attr) => {\n memo[attr.name] = attr.value\n return memo\n }, {})\n\n payload.tag = element.tagName\n payload.checked = element.checked\n payload.disabled = element.disabled\n assignElementValueToPayload(element, payload)\n\n return payload\n}\n\nexport {\n findClosestReflex,\n findClosestFrame,\n findFrameId,\n findFrame,\n findFrameSrc,\n buildAttributePayload\n}\n", "const registeredEvents = {}\nlet eventListener\n\nfunction registerEventListener (fn) {\n eventListener = fn\n}\n\nfunction registerEvent (eventName, tagNames) {\n registeredEvents[eventName] = tagNames\n document.addEventListener(eventName, eventListener, true)\n}\n\nfunction isRegisteredEvent (eventName, tagName) {\n tagName = tagName.toLowerCase()\n return (\n registeredEvents[eventName].includes(tagName) ||\n (!Object.values(registeredEvents)\n .flat()\n .includes(tagName) &&\n registeredEvents[eventName].includes('*'))\n )\n}\n\nfunction logRegisteredEvents () {\n console.log(registeredEvents)\n}\n\nexport {\n registerEventListener,\n registerEvent,\n registeredEvents,\n isRegisteredEvent,\n logRegisteredEvents\n}\n", "import './frame_sources'\nimport Security from './security'\nimport LifecycleEvents from './lifecycle_events'\nimport {\n findClosestReflex,\n findClosestFrame,\n findFrameId,\n findFrame,\n findFrameSrc,\n buildAttributePayload\n} from './elements'\nimport {\n registerEventListener,\n registerEvent,\n registeredEvents,\n isRegisteredEvent,\n logRegisteredEvents\n} from './event_registry'\n\n// fires before making a turbo HTTP request\naddEventListener('turbo:before-fetch-request', event => {\n const frame = event.target\n const { turboReflexActive } = frame.dataset\n if (!turboReflexActive) return\n const { fetchOptions } = event.detail\n fetchOptions.headers['Turbo-Reflex'] = Security.token\n})\n\nfunction buildURL (urlString) {\n const a = document.createElement('a')\n a.href = urlString\n return new URL(a)\n}\n\nfunction invokeFormReflex (form, payload = {}) {\n payload.token = Security.token\n const input = document.createElement('input')\n input.type = 'hidden'\n input.name = 'turbo_reflex'\n input.value = JSON.stringify(payload)\n form.appendChild(input)\n}\n\nfunction invokeReflex (event) {\n let element, frameId, frame, frameSrc\n try {\n element = findClosestReflex(event.target)\n if (!element) return\n\n if (!isRegisteredEvent(event.type, element.tagName)) return\n\n LifecycleEvents.dispatch(LifecycleEvents.beforeStart, element, { element })\n\n frameId = findFrameId(element)\n if (!frameId) return\n\n frame = findFrame(frameId)\n if (!frame) return\n\n frameSrc = findFrameSrc(frame)\n if (!frameSrc) return\n\n const payload = {\n frameId: frameId,\n element: buildAttributePayload(element)\n }\n\n LifecycleEvents.dispatch(LifecycleEvents.start, element, {\n element,\n frameId,\n frame,\n frameSrc,\n payload\n })\n frame.dataset.turboReflexActive = true\n frame.dataset.turboReflexElementId = element.id\n\n if (element.tagName.toLowerCase() === 'form')\n return invokeFormReflex(element, payload)\n\n event.preventDefault()\n const frameURL = buildURL(frameSrc)\n frameURL.searchParams.set('turbo_reflex', JSON.stringify(payload))\n frame.src = frameURL.toString()\n } catch (error) {\n console.error(\n `TurboReflex encountered an unexpected error!`,\n { element, frameId, frame, frameSrc, target: event.target },\n error\n )\n LifecycleEvents.dispatch(LifecycleEvents.error, element || document, {\n element,\n frameId,\n frame,\n frameSrc,\n error\n })\n }\n}\n\n// wire things up and setup default events\nregisterEventListener(invokeReflex)\nregisterEvent('change', ['input', 'select', 'textarea'])\nregisterEvent('submit', ['form'])\nregisterEvent('click', ['*'])\n\nexport default {\n registerEvent,\n logRegisteredEvents,\n logLifecycleEventNames: LifecycleEvents.logEventNames\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["events", "dispatch", "name", "target", "detail", "event", "logEventNames", "lifecycle_events_default", "frameSources", "event", "frame", "turboReflexActive", "turboReflexElementId", "element", "lifecycle_events_default", "Security", "security_default", "findClosestReflex", "element", "findClosestFrame", "findFrameId", "id", "frame", "lifecycle_events_default", "findFrame", "findFrameSrc", "frameSrc", "assignElementValueToPayload", "payload", "memo", "option", "buildAttributePayload", "attr", "registeredEvents", "eventListener", "registerEventListener", "fn", "registerEvent", "eventName", "tagNames", "isRegisteredEvent", "tagName", "logRegisteredEvents", "event", "frame", "turboReflexActive", "fetchOptions", "security_default", "buildURL", "urlString", "a", "invokeFormReflex", "form", "payload", "input", "invokeReflex", "element", "frameId", "frameSrc", "findClosestReflex", "isRegisteredEvent", "lifecycle_events_default", "findFrameId", "findFrame", "findFrameSrc", "buildAttributePayload", "frameURL", "error", "registerEventListener", "registerEvent", "turbo_reflex_default", "logRegisteredEvents"]
7
+ }
@@ -8,6 +8,7 @@ module TurboReflex::Controller
8
8
  after_action :append_turbo_reflex_turbo_streams, if: :turbo_reflex_performed?
9
9
  after_action :assign_turbo_reflex_token
10
10
  helper_method :turbo_reflex_meta_tag, :turbo_reflex_performed?, :turbo_reflex_requested?
11
+ # helper TurboReflex::TurboReflexHelper # only required if we isolate_namespace
11
12
  end
12
13
 
13
14
  def turbo_reflex_meta_tag
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboReflex::ApplicationController < ActionController::Base
4
+ include TurboReflex::Controller
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TurboReflex::TurboReflexesController < TurboReflex::ApplicationController
4
+ def show
5
+ return head(:ok) unless turbo_reflex_instance&.turbo_streams.present?
6
+ render html: view_context.turbo_reflex_frame_tag do
7
+ turbo_reflex_instance.turbo_streams.map(&:to_s).join
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboReflex::TurboReflexHelper
4
+ include Turbo::FramesHelper
5
+
6
+ def turbo_reflex_frame_tag(&block)
7
+ turbo_frame_tag("turbo-reflex-frame", data: {turbo_reflex_src: turbo_reflex_path}, &block)
8
+ end
9
+ end
@@ -9,7 +9,7 @@ function findClosestFrame (element) {
9
9
  }
10
10
 
11
11
  function findFrameId (element) {
12
- let id = element.dataset.turboReflexFrame || element.dataset.turboFrame
12
+ let id = element.dataset.turboFrame
13
13
  if (!id) {
14
14
  const frame = findClosestFrame(element)
15
15
  if (frame) id = frame.id
@@ -17,7 +17,7 @@ function findFrameId (element) {
17
17
  if (!id) {
18
18
  console.error(
19
19
  `The reflex element does not specify a frame!`,
20
- `Please move the reflex element inside a <turbo-frame> or set the 'data-turbo-reflex-frame' or 'data-turbo-frame' attribute.`,
20
+ `Please move the reflex element inside a <turbo-frame> or set the 'data-turbo-frame' attribute.`,
21
21
  element
22
22
  )
23
23
  LifecycleEvents.dispatch(LifecycleEvents.missingFrameId, element, {
data/bin/standardize CHANGED
@@ -3,3 +3,5 @@
3
3
  bundle exec magic_frozen_string_literal
4
4
  bundle exec standardrb --fix
5
5
  yarn run prettier-standard "app/javascript/**/*.js"
6
+ yarn run rustywind --write test/dummy/app
7
+ yarn run rustywind --write --custom-regex "(:\s[\"'])(.+)[\"']" test/dummy/app/views/_tailwind.yml.erb
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ match "turbo_reflex", to: "turbo_reflex/turbo_reflexes#show", via: :all, as: :turbo_reflex
5
+ end
@@ -0,0 +1,46 @@
1
+ version: "3.9"
2
+
3
+ x-default-app: &default_app
4
+ build: .
5
+ image: turbo_reflex
6
+ tty: true
7
+ stdin_open: true
8
+ working_dir: /opt/turbo_reflex/test/dummy
9
+ environment:
10
+ RAILS_ENV: development
11
+ volumes:
12
+ - bundle:/usr/local/bundle:delegated
13
+ - node_modules:/opt/turbo_reflex/node_modules
14
+
15
+ volumes:
16
+ bundle:
17
+ node_modules:
18
+
19
+ services:
20
+ # ----------------------------------------------------------------------------
21
+ # Shell - Intended for tinkering and running misc commands
22
+ # ----------------------------------------------------------------------------
23
+ shell:
24
+ <<: *default_app
25
+ container_name: turbo_reflex-shell
26
+ command: /bin/bash -c "tail -f /dev/null"
27
+
28
+ # ----------------------------------------------------------------------------
29
+ # Web - Runs the test/dummy Rails app
30
+ # ----------------------------------------------------------------------------
31
+ web:
32
+ <<: *default_app
33
+ container_name: turbo_reflex-web
34
+ ports:
35
+ - 3000:3000
36
+ command: >
37
+ /bin/bash -c "git pull --no-rebase github main &&
38
+ cd /opt/turbo_reflex &&
39
+ bundle &&
40
+ yarn &&
41
+ cd /opt/turbo_reflex/test/dummy &&
42
+ rm -f tmp/pids/server.pid &&
43
+ bin/rails db:create db:migrate &&
44
+ bin/rails assets:clobber &&
45
+ bin/rails assets:precompile &&
46
+ bin/rails s --binding=0.0.0.0 --port=3000"
data/fly.toml ADDED
@@ -0,0 +1,38 @@
1
+ # fly.toml file generated for turbo-reflex on 2022-09-19T05:16:01-06:00
2
+
3
+ app = "turbo-reflex"
4
+ kill_signal = "SIGINT"
5
+ kill_timeout = 5
6
+ processes = []
7
+
8
+ [env]
9
+
10
+ [experimental]
11
+ allowed_public_ports = []
12
+ auto_rollback = true
13
+
14
+ [[services]]
15
+ http_checks = []
16
+ internal_port = 3000
17
+ processes = ["app"]
18
+ protocol = "tcp"
19
+ script_checks = []
20
+ [services.concurrency]
21
+ hard_limit = 25
22
+ soft_limit = 20
23
+ type = "connections"
24
+
25
+ [[services.ports]]
26
+ force_https = true
27
+ handlers = ["http"]
28
+ port = 80
29
+
30
+ [[services.ports]]
31
+ handlers = ["tls", "http"]
32
+ port = 443
33
+
34
+ [[services.tcp_checks]]
35
+ grace_period = "1s"
36
+ interval = "15s"
37
+ restart_limit = 0
38
+ timeout = "2s"
@@ -3,6 +3,8 @@
3
3
  class TurboReflex::Base
4
4
  attr_reader :controller, :turbo_streams
5
5
 
6
+ delegate :render, to: :renderer
7
+
6
8
  def initialize(controller)
7
9
  @controller = controller
8
10
  @turbo_streams = Set.new
@@ -29,4 +31,8 @@ class TurboReflex::Base
29
31
  def turbo_stream
30
32
  @turbo_stream ||= Turbo::Streams::TagBuilder.new(controller.view_context)
31
33
  end
34
+
35
+ def renderer
36
+ ActionController::Renderer.for controller.class, controller.request.env
37
+ end
32
38
  end
@@ -2,8 +2,23 @@
2
2
 
3
3
  require "turbo-rails"
4
4
  require_relative "version"
5
+ require_relative "errors"
5
6
  require_relative "sanitizer"
6
7
  require_relative "base"
7
8
 
8
9
  class TurboReflex::Engine < ::Rails::Engine
10
+ # isolate_namespace TurboReflex
11
+
12
+ config.turbo_reflex = ActiveSupport::OrderedOptions.new
13
+ initializer "turbo_reflex.configuration" do
14
+ config.to_prepare do |app|
15
+ ::ApplicationController.send :include, TurboReflex::Controller
16
+ end
17
+
18
+ config.after_initialize do |app|
19
+ app.routes.draw do
20
+ mount TurboReflex::Engine => "/turbo_reflex"
21
+ end
22
+ end
23
+ end
9
24
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TurboReflex
4
+ class ResponseNotRenderedError < StandardError
5
+ end
6
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TurboReflex
4
- VERSION = "0.0.2"
4
+ VERSION = "0.0.4"
5
5
  end
data/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "turbo_reflex",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Reflexes for Turbo Frames that help you build robust reactive applications",
5
- "main": "app/assets/builds/turbo_reflex.js",
5
+ "main": "app/javascript/turbo_reflex.js",
6
6
  "repository": "https://github.com/hopsoft/turbo_reflex",
7
7
  "author": "Nate Hopkins (hopsoft) <natehop@gmail.com>",
8
8
  "license": "MIT",
@@ -12,9 +12,11 @@
12
12
  "devDependencies": {
13
13
  "esbuild": "^0.15.7",
14
14
  "eslint": "^8.19.0",
15
- "prettier-standard": "^16.4.1"
15
+ "flowbite": "^1.5.3",
16
+ "prettier-standard": "^16.4.1",
17
+ "rustywind": "^0.15.1"
16
18
  },
17
19
  "scripts": {
18
- "build": "esbuild app/javascript/turbo_reflex.js --bundle --minify --sourcemap --format=esm --outfile=app/assets/builds/turbo_reflex.min.js"
20
+ "build": "esbuild app/javascript/turbo_reflex.js --bundle --minify --sourcemap --format=esm --outfile=app/assets/builds/turbo_reflex.js"
19
21
  }
20
22
  }