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

Export the morph function #58

Merged
merged 9 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .config/tocer/configuration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
label: "## Table of Contents"
patterns:
- "README.md"
root_dir: "."
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ RUN apt-get -y update && \
apt-get -y --no-install-recommends install \
build-essential \
curl \
git \
htop \
libjemalloc2 \
pkg-config \
sqlite3 \
Expand Down
67 changes: 55 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
</h1>
<p align="center">
<a href="http://blog.codinghorror.com/the-best-code-is-no-code-at-all/">
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-261-47d299.svg" />
<img alt="Lines of Code" src="https://img.shields.io/badge/loc-295-47d299.svg" />
</a>
<a href="https://codeclimate.com/github/hopsoft/turbo_boost-streams/maintainability">
<img src="https://api.codeclimate.com/v1/badges/a6671f4294ec0f21f732/maintainability" />
Expand Down Expand Up @@ -79,6 +79,8 @@ You can `invoke` any DOM method on the client with Turbo Streams.
- [Usage](#usage)
- [Method Chaining](#method-chaining)
- [Event Dispatch](#event-dispatch)
- [Morph](#morph)
- [Morph Method](#morph-method)
- [Syntax Styles](#syntax-styles)
- [Extending Behavior](#extending-behavior)
- [Implementation Details](#implementation-details)
Expand Down Expand Up @@ -193,6 +195,56 @@ turbo_stream
.invoke(:dispatch_event, args: ["turbo-ready:demo", {bubbles: true, detail: {...}}]) # set event options
```

### Morph

You can morph elements with the `morph` method.

```ruby
turbo_stream.invoke(:morph, args: [render("path/to/partial")], selector: "#my-element")
```

> [!NOTE]
> TurboBoost Streams uses [Idiomorph](https:/bigskysoftware/idiomorph) for morphing.

The following options are used to morph elements.

```js
{
morphStyle: 'outerHTML',
ignoreActiveValue: true,
head: { style: 'merge' },
callbacks: { beforeNodeMorphed: (oldNode, _) => ... }
}
```

> [!TIP]
> The callbacks honor the `data-turbo-permanent` attribute and is aware of the [Trix](https://trix-editor.org/) editor.

### Morph Method

The morph method is also exported to the `TurboBoost.Streams` global and is available for client side morphing.

```js
TurboBoost.Streams.morph.method // → function(targetNode, htmlString, options = {})
```

You can also override the `morph` method if desired.

```js
TurboBoost.Streams.morph.method = (targetNode, htmlString, options = {}) => {
// your custom implementation
}
```

It also support adding a delay before morphing is performed.

```js
TurboBoost.Streams.morph.delay = 50 // → 50ms
```

> [!TIP]
> Complex test suites may require a delay to ensure the DOM is ready before morphing.

### Syntax Styles

You can use [`snake_case`](https://en.wikipedia.org/wiki/Snake_case) when invoking DOM functionality.
Expand All @@ -217,21 +269,12 @@ If you add new capabilities to the browser, you can control them from the server
// JavaScript on the client
import morphdom from 'morphdom'

window.MyNamespace = {
morph: (from, to, options = {}) => {
morphdom(document.querySelector(from), to, options)
}
}
window.MyNamespace = { coolStuff: (arg) => { ... } }
```

```ruby
# Ruby on the server
turbo_stream.invoke "MyNamespace.morph",
args: [
"#demo",
"<div id='demo'><p>You've changed...</p></div>",
{children_only: true}
]
turbo_stream.invoke "MyNamespace.coolStuff", args: ["Hello World!"]
```

### Implementation Details
Expand Down
15 changes: 9 additions & 6 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
# frozen_string_literal: true

require "bundler/setup"
require "bundler/gem_tasks"
require "rake/testtask"

APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)

load "rails/tasks/engine.rake"
load "rails/tasks/statistics.rake"
require "bundler/gem_tasks"

Rake::TestTask.new do |test|
test.libs << "test"
test.test_files = FileList["test/**/*_test.rb"]
test.warning = false
desc "Run tests"
task :test, [:file] do |_, args|
command = (ARGV.length > 1) ?
"bin/rails test #{ARGV[1..].join(" ")}" :
"bin/rails test:all"
puts command
exec command
end

task default: :test
2 changes: 1 addition & 1 deletion app/assets/builds/@turbo-boost/streams.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions app/assets/builds/@turbo-boost/streams.js.map

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion app/javascript/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import VERSION from './version'
import schema from './schema'
import morph from './morph'
import { invoke, invokeEvents } from './invoke'

if (!self['Turbo'])
Expand All @@ -14,7 +15,7 @@ if (!Turbo['StreamActions'])

Turbo.StreamActions.invoke = invoke
self.TurboBoost = self.TurboBoost || {}
self.TurboBoost.Streams = { invoke, invokeEvents, schema, VERSION }
self.TurboBoost.Streams = { invoke, invokeEvents, morph, schema, VERSION }

console.info('@turbo-boost/streams has initialized and registered new stream actions with Turbo.')

Expand Down
20 changes: 11 additions & 9 deletions app/javascript/invoke.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import morph from './morph'

export const invokeEvents = {
const invokeEvents = {
before: 'turbo-boost:stream:before-invoke',
after: 'turbo-boost:stream:after-invoke',
finish: 'turbo-boost:stream:finish-invoke'
Expand Down Expand Up @@ -30,16 +30,16 @@ function withInvokeEvents(receiver, detail, callback) {
if (result instanceof Promise) promise = result

if (promise)
promise.then(
() => {
promise
.then(() => {
options.detail.promise = 'fulfilled'
target.dispatchEvent(new CustomEvent(invokeEvents.finish, options))
},
() => {
})
.catch(error => {
options.detail.promise = 'rejected'
options.detail.error = error
target.dispatchEvent(new CustomEvent(invokeEvents.finish, options))
}
)
})
else target.dispatchEvent(new CustomEvent(invokeEvents.finish, options))
}

Expand All @@ -61,7 +61,7 @@ function invokeDispatchEvent(method, args, receivers) {
function invokeMorph(method, args, receivers) {
const html = args[0]
const detail = { method, html }
receivers.forEach(receiver => withInvokeEvents(receiver, detail, object => morph(object, html)))
receivers.forEach(receiver => withInvokeEvents(receiver, detail, object => morph.method(object, html)))
}

function invokeAssignment(method, args, receivers) {
Expand Down Expand Up @@ -93,7 +93,7 @@ function performInvoke(method, args, receivers) {
return invokeMethod(method, args, receivers)
}

export function invoke() {
function invoke() {
const payload = JSON.parse(this.templateContent.textContent)
const { id, selector, receiver, method, args, delay } = payload
let receivers = [{ object: self, target: self }]
Expand All @@ -118,3 +118,5 @@ export function invoke() {
if (delay > 0) setTimeout(() => performInvoke(method, args, receivers), delay)
else performInvoke(method, args, receivers)
}

export { invoke, invokeEvents }
71 changes: 52 additions & 19 deletions app/javascript/morph.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,69 @@
import { Idiomorph } from 'idiomorph'
import schema from './schema'

const input = /INPUT/i
const inputTypes = /date|datetime-local|email|month|number|password|range|search|tel|text|time|url|week/i
const textarea = /TEXTAREA/i
let _method
let _delay = 0

const trixEditor = /TRIX-EDITOR/i

const morphAllowed = node => {
if (node.nodeType !== Node.ELEMENT_NODE) return true
if (node !== document.activeElement) return true
function isElement(node) {
return node.nodeType === Node.ELEMENT_NODE
}

// don't morph elements marked as turbo permanent
if (
function isTurboPermanent(node) {
if (!isElement(node)) return false
return (
node.hasAttribute(schema.turboPermanentAttribute) &&
node.getAttribute(schema.turboPermanentAttribute) !== 'false'
)
return false
}

// don't morph active textarea
if (node.tagName.match(textarea)) return false
function isActive(node) {
if (!isElement(node)) return false
return node === document.activeElement
}

// don't morph active trix-editor
if (node.tagName.match(trixEditor)) return false
function morphAllowed(node) {
if (isTurboPermanent(node)) return false
if (isActive(node) && node.tagName.match(trixEditor)) return false
return true
}

// don't morph active inputs
return node.tagName.match(input) && node.getAttribute('type').match(inputTypes)
const defaultOptions = {
callbacks: { beforeNodeMorphed: (oldNode, _newNode) => morphAllowed(oldNode) },
morphStyle: 'outerHTML',
ignoreActiveValue: true,
head: { style: 'merge' }
}

const callbacks = {
beforeNodeMorphed: (oldNode, _newNode) => morphAllowed(oldNode)
function morph(element, html, options = {}) {
const callbacks = { ...defaultOptions.callbacks, ...options.callbacks }
options = { ...defaultOptions, ...options, callbacks }

return new Promise(resolve => {
setTimeout(() => {
Idiomorph.morph(element, html, options)
resolve()
}, _delay)
})
}

const morph = (element, html) => Idiomorph.morph(element, html, { callbacks })
_method = morph

export default {
get delay() {
return _delay
},

set delay(ms) {
_delay = ms
},

export default morph
get method() {
return _method
},

set method(fn) {
_method = fn
}
}
14 changes: 14 additions & 0 deletions bin/rails
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env ruby
# This command will automatically be run when you run "rails" with Rails gems
# installed from the root of your application.

ENGINE_ROOT = File.expand_path("..", __dir__)
ENGINE_PATH = File.expand_path("../lib/xengine/engine", __dir__)
APP_PATH = File.expand_path("../test/dummy/config/application", __dir__)

# Set up gems listed in the Gemfile.
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"])

require "rails/all"
require "rails/engine/commands"
4 changes: 1 addition & 3 deletions bin/test
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#!/usr/bin/env ruby
$: << File.expand_path("../test", __dir__)

require "bundler/setup"
require "rails/plugin/test"
exec "rake test #{ARGV.join " "}".strip
Loading
Loading