Skip to content

Commit

Permalink
Support turbo-stream[action] on turbo-frames
Browse files Browse the repository at this point in the history
In parallel with `<turbo-stream action="...">`, this commit adds support
for the same rendering operations that Turbo Stream elements support.

By default, `<turbo-frame>` elements will render (and have historically
been rendering) with the `update` behavior.

This commit extends that support to also include the other actions:

* `append` will extract the contents out of the response frame and
  append them into the request frame
* `prepend` will extract the contents out of the response frame and
  append them into the request frame
* `replace` will extract the contents out of the response frame, remove
  the request frame, and inject the extracted contents in its place
  (conceptually similar to setting `outerHTML`)
* `remove` will remove the request frame, and ignore the contents of the
  response frame
* `update` (the default behavior) will extract the contents from the
  response frame, remove the contents of the request frame, and inject
  the response frame's contents (conceptually similar to setting
  `innerHTML`)

This will enable behaviors that might have been achievable with
`GET`-request powered Turbo Stream responses.

For example, in-place pagination could be achieved with
`action="prepend"` or `action="append"`:

```html
<!-- current HTML -->
<turbo-frame id="posts" action="append">
  <article id="article_1"><!-- contents --></article>
  <!-- articles 2-9 -->
  <article id="article_10"><!-- contents --></article>

  <a href="/posts?page=2">Next page</a>
</turbo-frame>

<!-- response HTML -->
<turbo-frame id="posts" action="append">
  <article id="article_11"><!-- contents --></article>

  <a href="/posts?page=3">Next page</a>
</turbo-frame>

<!-- HTML after the request -->
<turbo-frame id="posts" action="append">
  <article id="article_1"><!-- contents --></article>
  <!-- articles 2-9 -->
  <article id="article_10"><!-- contents --></article>
  <a href="/posts?page=2">Next page</a>
  <article id="article_11"><!-- contents --></article>

  <a href="/posts?page=3">Next page</a>
</turbo-frame>
```

Through the power of a CSS rules utilizing `:last-of-type`, we can hide
the pagination links:

```css
a {
  display: none;
}

a:last-of-type {
  display: block;
}
```
  • Loading branch information
seanpdoyle committed Jan 31, 2021
1 parent 57a118e commit 1b57990
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 14 deletions.
2 changes: 1 addition & 1 deletion src/core/frames/frame_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class FrameController implements AppearanceObserverDelegate, FetchRequest
if (html) {
const { body } = parseHTMLDocument(html)
const snapshot = new Snapshot(await this.extractForeignFrameElement(body))
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false)
const renderer = new FrameRenderer(this.view.snapshot, snapshot, false, this.element.action)
await this.view.render(renderer)
}
} catch (error) {
Expand Down
47 changes: 37 additions & 10 deletions src/core/frames/frame_renderer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { FrameElement } from "../../elements/frame_element"
import { FrameElement, RenderAction } from "../../elements/frame_element"
import { nextAnimationFrame } from "../../util"
import { Renderer } from "../renderer"
import { Snapshot } from "../snapshot"

export class FrameRenderer extends Renderer<FrameElement> {
readonly renderAction: RenderAction
constructor(currentSnapshot: Snapshot<FrameElement>, newSnapshot: Snapshot<FrameElement>, isPreview: boolean, renderAction: RenderAction) {
super(currentSnapshot, newSnapshot, isPreview)
this.renderAction = renderAction
}

get shouldRender() {
return true
}
Expand All @@ -18,15 +25,35 @@ export class FrameRenderer extends Renderer<FrameElement> {
}

loadFrameElement() {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(this.currentElement)
destinationRange.deleteContents()

const frameElement = this.newElement
const sourceRange = frameElement.ownerDocument?.createRange()
if (sourceRange) {
sourceRange.selectNodeContents(frameElement)
this.currentElement.appendChild(sourceRange.extractContents())
const sourceRange = this.newElement.ownerDocument?.createRange()
sourceRange.selectNodeContents(this.newElement)

switch (this.renderAction) {
case RenderAction.remove:
this.currentElement.remove()
break
case RenderAction.append:
if (sourceRange) {
this.currentElement.append(sourceRange.extractContents())
}
break
case RenderAction.prepend:
if (sourceRange) {
this.currentElement.prepend(sourceRange.extractContents())
}
break
case RenderAction.replace:
if (sourceRange) {
this.currentElement.replaceWith(sourceRange.extractContents())
}
break
default:
const destinationRange = document.createRange()
destinationRange.selectNodeContents(this.currentElement)
destinationRange.deleteContents()
if (sourceRange) {
this.currentElement.appendChild(sourceRange.extractContents())
}
}
}

Expand Down
30 changes: 30 additions & 0 deletions src/elements/frame_element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@ import { FetchResponse } from "../http/fetch_response"

export enum FrameLoadingStyle { eager = "eager", lazy = "lazy" }

export enum RenderAction {
append = "append",
prepend = "prepend",
remove = "remove",
replace = "replace",
update = "update",
}

export function renderActionFromString(action: string | null): RenderAction {
switch (action?.toLowerCase()) {
case "append": return RenderAction.append
case "prepend": return RenderAction.prepend
case "remove": return RenderAction.remove
case "replace": return RenderAction.replace
default: return RenderAction.update
}
}

export interface FrameElementDelegate {
connect(): void
disconnect(): void
Expand Down Expand Up @@ -43,6 +61,18 @@ export class FrameElement extends HTMLElement {
}
}

get action(): RenderAction {
return renderActionFromString(this.getAttribute("action"))
}

set action(value: RenderAction) {
if (value) {
this.setAttribute("action", renderActionFromString(value))
} else {
this.removeAttribute("action")
}
}

get src() {
return this.getAttribute("src")
}
Expand Down
10 changes: 7 additions & 3 deletions src/tests/fixtures/frames.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
<body>
<h1>Frames</h1>

<turbo-frame id="frame">
<h2>Frames: #frame</h2>
</turbo-frame>
<div id="frame-with-action">
<turbo-frame id="frame">
<h2>Frames: #frame</h2>
</turbo-frame>

<a href="/src/tests/fixtures/frames/frame.html" data-turbo-frame="frame">Load #frame</a>
</div>

<turbo-frame id="hello" target="frame">
<h2>Frames: #hello</h2>
Expand Down
57 changes: 57 additions & 0 deletions src/tests/functional/frame_tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,63 @@ export class FrameTests extends FunctionalTestCase {
const frameText = await this.querySelector("body > h1")
this.assert.equal(await frameText.getVisibleText(), "One")
}

async "test frame with action=append appends the contents"() {
await this.remote.execute(() => document.getElementById("frame")?.setAttribute("action", "append"))
await this.clickSelector("#frame-with-action a:first-of-type")
await this.nextBeat

this.assert.ok(await this.hasSelector("#frame"), "preserves existing frame")
this.assert.equal(await this.getVisibleText("#frame h2:first-of-type"), "Frames: #frame")
this.assert.equal(await this.getVisibleText("#frame h2:last-of-type"), "Frame: Loaded")
}

async "test frame with action=prepend appends the contents"() {
await this.remote.execute(() => document.getElementById("frame")?.setAttribute("action", "prepend"))
await this.clickSelector("#frame-with-action a:first-of-type")
await this.nextBeat

this.assert.ok(await this.hasSelector("#frame"), "preserves existing frame")
this.assert.equal(await this.getVisibleText("#frame h2:first-of-type"), "Frame: Loaded")
this.assert.equal(await this.getVisibleText("#frame h2:last-of-type"), "Frames: #frame")
}

async "test frame with action=remove removes the element"() {
await this.remote.execute(() => document.getElementById("frame")?.setAttribute("action", "remove"))
await this.clickSelector("#frame-with-action a:first-of-type")
await this.nextBeat

this.assert.notOk(await this.hasSelector("turbo-frame#frame"), "removes existing frame")
this.assert.notOk(await this.hasSelector("#frame-with-action h2"))
}

async "test frame with action=replace sets outerHTML"() {
await this.remote.execute(() => document.getElementById("frame")?.setAttribute("action", "replace"))
await this.clickSelector("#frame-with-action a:first-of-type")
await this.nextBeat

this.assert.notOk(await this.hasSelector("turbo-frame#frame"), "removes existing frame")
this.assert.equal(await this.getVisibleText("#frame-with-action h2:first-of-type"), "Frame: Loaded")
}

async "test frame without action defaults to action=update"() {
await this.remote.execute(() => document.getElementById("frame")?.removeAttribute("action"))
await this.clickSelector("#frame-with-action a:first-of-type")
await this.nextBeat

this.assert.ok(await this.hasSelector("turbo-frame#frame"), "preserves existing frame")
this.assert.equal(await this.getVisibleText("#frame h2"), "Frame: Loaded")
}

async "test frame with action=update sets innerHTML"() {
await this.remote.execute(() => document.getElementById("frame")?.setAttribute("action", "update"))
await this.clickSelector("#frame-with-action a:first-of-type")
await this.nextBeat

this.assert.ok(await this.hasSelector("turbo-frame#frame"))
this.assert.equal(await this.getVisibleText("#frame h2"), "Frame: Loaded")
}

}

FrameTests.registerSuite()
4 changes: 4 additions & 0 deletions src/tests/helpers/functional_test_case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export class FunctionalTestCase extends InternTestCase {
return this.remote.findByCssSelector(selector).click()
}

async getVisibleText(selector: string): Promise<string> {
return this.querySelector(selector).then(element => element.getVisibleText())
}

async scrollToSelector(selector: string): Promise<void> {
const element = await this.remote.findByCssSelector(selector)
return this.evaluate(element => element.scrollIntoView(), element)
Expand Down

0 comments on commit 1b57990

Please sign in to comment.