Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Activate <script> for GET Form Submissions #815

Closed
tbcooney opened this issue Dec 10, 2022 · 5 comments
Closed

Activate <script> for GET Form Submissions #815

tbcooney opened this issue Dec 10, 2022 · 5 comments

Comments

@tbcooney
Copy link

tbcooney commented Dec 10, 2022

In every non-trivial B2B application you’ll have to provide users with reports and charts to analyze data. Currently, Turbo does not evaluate <script> tags in the response from GET form submissions with the Accept turbo stream or html content type. The GET form submission is preferred over a POST, as the URL not changing can be problematic. For example, a user may wish to update the URL of the page to reflect the currently applied date range so that the user can copy/paste the URL to share a specific view of the report.

It would be really useful for building reactive reports if we could "activate" <script> elements immediately in responses from the server:

<div class="flex justify-end mb-1">
  <%= form_with url: [:reports, :sales], method: :get, data: { turbo_frame: "sales", turbo_action: "advance" } do |form| %>
    <%= form.date_field :start_date, value: params[:start_date], class: "form-input" %>
    <%= form.date_field :end_date, value: params[:end_date], class: "form-input" %>
  <% end %>
</div>
<%= turbo_frame_tag "sales" do %>
  <%= column_chart @sales %>
  <!-- Sales table -->
<% end %>

The form above uses a data-turbo-frame to target the sales frame and tells Turbo to use the response from the form submission to replace the content of the sales frame. The page URL is also updated by adding a turbo-action data attribute to the form that triggers the frame navigation.

After submitting the form, the chart body appears but the <script> function is not executed so the chart is not displayed.

<turbo-frame id="sales" src="http://lvh.me:5000/reports/sales?start_date=2022-11-01&end_date=2022-11-04" complete="">
  <div id="chart-1" style="height: 280px; width: 100%; text-align: center; color: #999; line-height: 280px; font-size: 14px; font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Arial, Helvetica, sans-serif;"><canvas style="box-sizing: border-box;" width="300"></canvas></div>
  <script>
    (function() {
      if (document.documentElement.hasAttribute("data-turbolinks-preview")) return;
      if (document.documentElement.hasAttribute("data-turbo-preview")) return;

      var createChart = function() { new Chartkick["ColumnChart"]("chart-1", [["2022-11-01","0"],["2022-11-02","0"],["2022-11-03","0"],["2022-11-04","0"]], {"legend":false,"colors":["#000","#000"],"prefix":"$","round":2,"zeros":true,"thousands":","}); };
      if ("Chartkick" in window) {
        createChart();
      } else {
        window.addEventListener("chartkick:load", createChart, true);
      }
    })();
  </script>
</turbo-frame>

When loading this same view on the initial HTML request a canvas element is created with the actual chart rendered. So I presume the issue is that the <script> is not being called after Turbo replaces the content of the sales frame - but I don't know why.

@seanpdoyle did you mention this was an anti-pattern in a comment somewhere? I was curious as to the correct Turbo way to approach this, as this seems like a great use case to compliment #449.

Options to work around this include using Turbo Streams with specific GET requests by replacing data-turbo-frame with data-turbo-stream="true" on the form, however, this appears to leave the URL unchanged as it's a stream action that doesn't involve a page visit. Moving the chart to a separate turbo_frame_tag also evaluates and activates the <script> tag each time the form is submitted:

<div class="flex justify-end mb-1">
  <%= form_with url: [:reports, :sales], method: :get, data: { turbo_frame: "sales", turbo_action: "advance" } do |form| %>
    <%= form.date_field :start_date, value: params[:start_date], class: "form-input" %>
    <%= form.date_field :end_date, value: params[:end_date], class: "form-input" %>
  <% end %>
</div>
<%= turbo_frame_tag "sales", class: "shadow overflow-hidden rounded border-b border-gray-200" do %>
  <%= turbo_frame_tag :chart, src: chart_reports_sales_path %>
  <!-- Sales table -->
<% end %>
@tbcooney tbcooney changed the title Activate <script> for GET Form Submissions Activate <script> for GET Form Submissions Dec 10, 2022
@seanpdoyle
Copy link
Contributor

When loading this same view on the initial HTML request a canvas element is created with the actual chart rendered. So I presume the issue is that the <script> is not being called after Turbo replaces the content of the sales frame - but I don't know why.

@seanpdoyle did you mention this was an anti-pattern in a comment somewhere? I was curious as to the correct Turbo way to approach this, as this seems like a great use case to compliment #449.

First of all, I would expect a <script> to execute its contents whenever its connected to the document, regardless of whether or not its connected during the initial render, as part of a Turbo Visit, a Turbo Stream render, or a Turbo Frame load.

I'm not sure of the exact comment you're referring to, but as far as considering code in this style as an anti-pattern goes, I think it could be improved by adopting Stimulus and some Stimulus idioms.

For example, introducing a chartkick Stimulus controller could help encapsulate the code responsible for listening loading the chart:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.createChart()
  }

  createChart() {
    // ...
  }
}

Then, you can route the chartkick:load event from within the template:

-<turbo-frame id="sales" src="http://lvh.me:5000/reports/sales?start_date=2022-11-01&end_date=2022-11-04" complete="">
+<turbo-frame id="sales" src="http://lvh.me:5000/reports/sales?start_date=2022-11-01&end_date=2022-11-04" complete=""
+  data-controller="chartkick" data-action="chartkick:load@window->chartkick#createChart">
   <div id="chart-1" style="height: 280px; width: 100%; text-align: center; color: #999; line-height: 280px; font-size: 14px; font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Arial, Helvetica, sans-serif;">
     <canvas style="box-sizing: border-box;" width="300"></canvas>
   </div>
</turbo-frame>

With that controller in place, you could render the chart's data as JSON inside a Stimulus Value or as the text content of a <script type="application/json"> element that's made accessible as a Stimulus Target:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["chart", "data", "configuration"]

  connect() {
    this.createChart()
  }

  createChart() {
    if (document.documentElement.hasAttribute("data-turbolinks-preview")) return
    if (document.documentElement.hasAttribute("data-turbo-preview")) return

    if ("Chartkick" in window && this.hasChartTarget && this.hasDataTarget && this.hasConfigurationTarget) {
      const chart = this.chartTarget.id
      const data = JSON.parse(this.dataTarget.text)
      const configuration = JSON.parse(this.configurationTarget.text)

      new Chartkick["ColumnChart"](this.chartTarget, data, configuration)
    }
  }
}

Then, you could render what's necessary into the HTML:

 <turbo-frame id="sales" src="http://lvh.me:5000/reports/sales?start_date=2022-11-01&end_date=2022-11-04" complete=""
   data-controller="chartkick" data-action="chartkick:load@window->chartkick#createChart">
-  <div id="chart-1" style="height: 280px; width: 100%; text-align: center; color: #999; line-height: 280px; font-size: 14px; font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Arial, Helvetica, sans-serif;">
+  <div id="chart-1" style="height: 280px; width: 100%; text-align: center; color: #999; line-height: 280px; font-size: 14px; font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Arial, Helvetica, sans-serif;"
+    data-chartkick-target="chart">
     <canvas style="box-sizing: border-box;" width="300"></canvas>
   </div>
+  <script type="application/json" data-chartkick-target="data"><%= ... %></script>
+  <script type="application/json" data-chartkick-target="configuration"><%= ... %></script>
</turbo-frame>

Since the <script> elements are inert and only serve to encode the JSON, the fact that (currently) Turbo doesn't evaluate them shouldn't pose any problems.

Then, since your JavaScript logic is out of your templates and into your Asset Pipeline (or other build chain), you have all the power of JavaScript classes at your disposal, including extracting properties:

  createChart() {
    if (this.pageIsPreview) return

    if (this.canRender) {
      new Chartkick["ColumnChart"](...this.chartkickArguments)
    }
  }

  get chartkickArguments() {
    return [
      this.chartTarget.id,
      JSON.parse(this.dataTarget.text),
      JSON.parse(this.configurationTarget.text)
    ]
  }

  get pageIsPreview() {
    return document.documentElement.hasAttribute("data-turbolinks-preview") ||
           document.documentElement.hasAttribute("data-turbo-preview")
  }

  get canRender() {
    return "Chartkick" in window && this.hasChartTarget && this.hasDataTarget && this.hasConfigurationTarget
  }

@seanpdoyle
Copy link
Contributor

seanpdoyle commented Dec 10, 2022

@tbcooney which version of Turbo does your application depend on?

As of 7.0.0-beta.8, <turbo-frame> elements have supported evaluating <script> children.

There are tests in the suite dedicated to exercising that behavior:

Along with fixture HTML that's very similar to the sample code you've shared in this issue:

    <turbo-frame id="body-script" data-loaded-from="/src/tests/fixtures/frames/body_script_2.html">
      <script>
        if ("frameScriptEvaluationCount" in window) {
          window.frameScriptEvaluationCount++
        } else {
          window.frameScriptEvaluationCount = 1
        }
      </script>
      <a id="body-script-link" href="/src/tests/fixtures/frames/body_script.html">Load #body-script</a>
    </turbo-frame>

@tbcooney
Copy link
Author

tbcooney commented Dec 10, 2022

@tbcooney which version of Turbo does your application depend on?

The application is using version 7.2.4 of Turbo. To your other point, this defiantly works when coupled with a Stimulus controller. My initial thought was just to use the Turbo Frame and the chart would be rendered (as of 7.0.0-beta.8). It seems intuitive to do that. I was surprised when it didn't work.

I spent some time looking through activateScriptElements introduced in #192 before opening this issue -- do <turbo-frame> elements evaluate <script> children after a form submission?

If this is something we should change, let me know and I'll open a PR 👍

@tbcooney
Copy link
Author

tbcooney commented Dec 10, 2022

Investigating a little further, if I replace the chart in the view with a <turbo-frame> and link_to helper to generate the chart and refresh frame, the <script> tag is evaluated in the response. That describes the tests in the suite dedicated to asserting this behaviour.

<div class="flex justify-end mb-1">
  <%= form_with url: [:reports, :sales], method: :get, data: { turbo_frame: "sales", turbo_action: "advance" } do |form| %>
    <%= form.date_field :start_date, value: params[:start_date], class: "form-input" %>
    <%= form.date_field :end_date, value: params[:end_date], class: "form-input" %>
  <% end %>
</div>
<%= turbo_frame_tag "sales", class: "shadow overflow-hidden rounded border-b border-gray-200" do %>
  <%= turbo_frame_tag "chart" do %>
    <%= link_to "Load chart", chart_reports_sales_path %>
  <% end %>
  <!-- Sales table -->
<% end %>

The above link_to "Load chart" will find the frame in the Chart view:

## app/views/reports/sales/chart.html.erb
<%= turbo_frame_tag "chart" do %>
  <%= link_to "Load chart", chart_reports_sales_path %>
  <%= column_chart @sales %>
<% end %>

So I presume the issue is that the <script> is not being called after Turbo replaces the content of the sales frame after a form submission.

From what I can see in the HTML, the difference is that:

During navigation, the link_to helper will make the <turbo-frame> transition from busy aria-busy="true" to complete whereas the response from GET form submissions will make the <turbo-frame> transition from complete busy aria-busy="true" to complete.

It seems that form submission will not from remove the [complete] attribute during navigation and instead build on top of it. Is that to be expected?

@seanpdoyle
Copy link
Contributor

I spent some time looking through activateScriptElements introduced in #192 before opening this issue -- do <turbo-frame> elements evaluate <script> children after a form submission?

I've tried to reproduce the behavior you're describing in the test suite:

diff --git a/src/tests/fixtures/frames.html b/src/tests/fixtures/frames.html
index c308995..4544942 100644
--- a/src/tests/fixtures/frames.html
+++ b/src/tests/fixtures/frames.html
@@ -101,10 +101,20 @@
 
     <turbo-frame id="body-script" target="body-script">
       <a id="body-script-link" href="/src/tests/fixtures/frames/body_script.html">Load body-script</a>
+
+      <form id="body-script-form" method="post" action="/__turbo/redirect">
+        <input type="hidden" name="path" value="/src/tests/fixtures/frames/body_script.html">
+        <button>redirect to body-script</button>
+      </form>
     </turbo-frame>
 
     <turbo-frame id="eval-false-script" target="eval-false-script">
       <a id="eval-false-script-link" href="/src/tests/fixtures/frames/eval_false_script.html">data-turbo-eval=false script</a></p>
+
+      <form id="eval-false-script-form" method="post" action="/__turbo/redirect">
+        <input type="hidden" name="path" value="/src/tests/fixtures/frames/eval_false_script.html">
+        <button>redirect to data-turbo-eval=false script</button>
+      </form>
     </turbo-frame>
 
     <turbo-frame id="recursive" recurse="composer" src="/src/tests/fixtures/frames/recursive.html">
diff --git a/src/tests/fixtures/frames/body_script.html b/src/tests/fixtures/frames/body_script.html
index 2d77614..1b91bf7 100644
--- a/src/tests/fixtures/frames/body_script.html
+++ b/src/tests/fixtures/frames/body_script.html
@@ -15,6 +15,11 @@
         }
       </script>
       <a id="body-script-link" href="/src/tests/fixtures/frames/body_script_2.html">Load #body-script</a>
+
+      <form id="body-script-form" method="post" action="/__turbo/redirect">
+        <input type="hidden" name="path" value="/src/tests/fixtures/frames/body_script_2.html">
+        <button>redirect to body-script</button>
+      </form>
     </turbo-frame>
   </body>
 </html>
diff --git a/src/tests/functional/frame_tests.ts b/src/tests/functional/frame_tests.ts
index 4242d1f..f0ef338 100644
--- a/src/tests/functional/frame_tests.ts
+++ b/src/tests/functional/frame_tests.ts
@@ -393,7 +393,7 @@ test("test removing [disabled] attribute from eager-loaded frame navigates it",
   await nextEventOnTarget(page, "frame", "turbo:before-fetch-request")
 })
 
-test("test evaluates frame script elements on each render", async ({ page }) => {
+test("test evaluates frame script elements on each link navigation", async ({ page }) => {
   assert.equal(await frameScriptEvaluationCount(page), undefined)
 
   await page.click("#body-script-link")
@@ -403,12 +403,28 @@ test("test evaluates frame script elements on each render", async ({ page }) =>
   assert.equal(await frameScriptEvaluationCount(page), 2)
 })
 
-test("test does not evaluate data-turbo-eval=false scripts", async ({ page }) => {
+test("test evaluates frame script elements on each form submission", async ({ page }) => {
+  assert.equal(await frameScriptEvaluationCount(page), undefined)
+
+  await page.click("#body-script-form button")
+  assert.equal(await frameScriptEvaluationCount(page), 1)
+
+  await page.click("#body-script-form button")
+  assert.equal(await frameScriptEvaluationCount(page), 2)
+})
+
+test("test does not evaluate data-turbo-eval=false scripts during link navigation", async ({ page }) => {
   await page.click("#eval-false-script-link")
   await nextBeat()
   assert.equal(await frameScriptEvaluationCount(page), undefined)
 })
 
+test("test does not evaluate data-turbo-eval=false scripts during form submission", async ({ page }) => {
+  await page.click("#eval-false-script-form button")
+  await nextBeat()
+  assert.equal(await frameScriptEvaluationCount(page), undefined)
+})
+
 test("test redirecting in a form is still navigatable after redirect", async ({ page }) => {
   await page.click("#navigate-form-redirect")
   await nextEventOnTarget(page, "form-redirect", "turbo:frame-load")

The tests that I've added pass without any implementation changes.

If this is something we should change, let me know and I'll open a PR

If you're able to reproduce the behavior you're describing, and are able to write some failing tests, I think a PR would be great start. Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

3 participants