refactor(turbo): simplify custom actions implementation now that turbo has support for it

This commit is contained in:
Paul Chavard 2022-10-11 23:39:26 +02:00
parent 2e9f87a629
commit 85581e72e9
10 changed files with 128 additions and 223 deletions

View file

@ -4,50 +4,70 @@ module TurboStreamHelper
end
class TagBuilder < Turbo::Streams::TagBuilder
def dispatch(type, detail = {})
append_all('turbo-events', partial: 'layouts/turbo_event', locals: { type: type, detail: detail })
end
include ActionView::Helpers::TagHelper
def show(target, delay: nil)
dispatch('dom:mutation', { action: :show, target: target, delay: delay }.compact)
turbo_stream_simple_action_tag :show, target: target, delay: delay
end
def show_all(targets, delay: nil)
dispatch('dom:mutation', { action: :show, targets: targets, delay: delay }.compact)
turbo_stream_simple_action_tag :show, targets: targets, delay: delay
end
def hide(target, delay: nil)
dispatch('dom:mutation', { action: :hide, target: target, delay: delay }.compact)
turbo_stream_simple_action_tag :hide, target: target, delay: delay
end
def hide_all(targets, delay: nil)
dispatch('dom:mutation', { action: :hide, targets: targets, delay: delay }.compact)
turbo_stream_simple_action_tag :hide, targets: targets, delay: delay
end
def focus(target)
dispatch('dom:mutation', { action: :focus, target: target })
turbo_stream_simple_action_tag :focus, target: target
end
def focus_all(targets)
dispatch('dom:mutation', { action: :focus, targets: targets })
end
def disable(target)
dispatch('dom:mutation', { action: :disable, target: target })
turbo_stream_simple_action_tag :focus, targets: targets
end
def enable(target)
dispatch('dom:mutation', { action: :enable, target: target })
turbo_stream_simple_action_tag :enable, target: target
end
def enable_all(targets)
turbo_stream_simple_action_tag :enable, targets: targets
end
def disable(target)
turbo_stream_simple_action_tag :disable, target: target
end
def disable_all(targets)
turbo_stream_simple_action_tag :disable, targets: targets
end
def morph(target, content = nil, **rendering, &block)
template = render_template(target, content, allow_inferred_rendering: true, **rendering, &block)
dispatch('dom:mutation', { action: :morph, target: target, html: template })
action :morph, target, content, **rendering, &block
end
def morph_all(targets, content = nil, **rendering, &block)
template = render_template(targets, content, allow_inferred_rendering: true, **rendering, &block)
dispatch('dom:mutation', { action: :morph, targets: targets, html: template })
action_all :morph, targets, content, **rendering, &block
end
def dispatch(type, detail = {})
turbo_stream_simple_action_tag(:dispatch, 'event-type': type, 'event-detail': detail.to_json)
end
private
def turbo_stream_simple_action_tag(action, target: nil, targets: nil, **attributes)
if (target = convert_to_turbo_stream_dom_id(target))
tag.turbo_stream('', **attributes.merge(action: action, target: target))
elsif (targets = convert_to_turbo_stream_dom_id(targets, include_selector: true))
tag.turbo_stream('', **attributes.merge(action: action, targets: targets))
else
tag.turbo_stream('', **attributes.merge(action: action))
end
end
end
end

View file

@ -1,134 +0,0 @@
import invariant from 'tiny-invariant';
import { z } from 'zod';
import morphdom from 'morphdom';
import { ApplicationController, Detail } from './application_controller';
export class TurboEventController extends ApplicationController {
static values = {
type: String,
detail: Object
};
declare readonly typeValue: string;
declare readonly detailValue: Detail;
connect(): void {
this.globalDispatch(this.typeValue, this.detailValue);
this.element.remove();
}
}
const MutationAction = z.enum([
'show',
'hide',
'focus',
'enable',
'disable',
'morph'
]);
type MutationAction = z.infer<typeof MutationAction>;
const Mutation = z.union([
z.object({
action: MutationAction,
delay: z.number().optional(),
target: z.string(),
html: z.string().optional()
}),
z.object({
action: MutationAction,
delay: z.number().optional(),
targets: z.string(),
html: z.string().optional()
})
]);
type Mutation = z.infer<typeof Mutation>;
addEventListener('dom:mutation', (event) => {
const detail = (event as CustomEvent).detail;
const mutation = Mutation.parse(detail);
mutate(mutation);
});
const Mutations: Record<MutationAction, (mutation: Mutation) => void> = {
hide: (mutation) => {
for (const element of findElements(mutation)) {
element.classList.add('hidden');
}
},
show: (mutation) => {
for (const element of findElements(mutation)) {
element.classList.remove('hidden');
}
},
focus: (mutation) => {
for (const element of findElements(mutation)) {
element.focus();
}
},
disable: (mutation) => {
for (const element of findElements<HTMLInputElement>(mutation)) {
element.disabled = true;
}
},
enable: (mutation) => {
for (const element of findElements<HTMLInputElement>(mutation)) {
element.disabled = false;
}
},
morph: (mutation) => {
invariant(mutation.html, 'morph action requires html');
for (const element of findElements<HTMLInputElement>(mutation)) {
morphdom(element, mutation.html, {
onBeforeElUpdated(fromEl, toEl) {
if (isTouchedInput(fromEl)) {
fromEl.removeAttribute('data-touched');
mergeInputValue(
fromEl as HTMLInputElement,
toEl as HTMLInputElement
);
}
if (fromEl.isEqualNode(toEl)) {
return false;
}
return true;
}
});
}
}
};
function mergeInputValue(fromEl: HTMLInputElement, toEl: HTMLInputElement) {
toEl.value = fromEl.value;
toEl.checked = fromEl.checked;
}
function isTouchedInput(element: HTMLElement): boolean {
return (
['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) &&
!!element.getAttribute('data-touched')
);
}
function mutate(mutation: Mutation) {
const fn = Mutations[mutation.action];
invariant(fn, `Could not find mutation ${mutation.action}`);
if (mutation.delay) {
setTimeout(() => fn(mutation), mutation.delay);
} else {
fn(mutation);
}
}
function findElements<Element extends HTMLElement = HTMLElement>(
mutation: Mutation
): Element[] {
if ('target' in mutation) {
const element = document.querySelector<Element>(`#${mutation.target}`);
invariant(element, `Could not find element with id ${mutation.target}`);
return [element];
} else if ('targets' in mutation) {
return [...document.querySelectorAll<Element>(mutation.targets)];
}
invariant(false, 'Could not find element');
}

View file

@ -4,6 +4,7 @@ import * as Turbo from '@hotwired/turbo';
import { Application } from '@hotwired/stimulus';
import '@gouvfr/dsfr/dist/dsfr.module.js';
import '../shared/turbo-actions';
import '../shared/activestorage/ujs';
import '../shared/safari-11-empty-file-workaround';
import '../shared/toggle-target';

View file

@ -0,0 +1,83 @@
import { StreamActions, TurboStreamAction } from '@hotwired/turbo';
import morphdom from 'morphdom';
const hide: TurboStreamAction = function () {
this.targetElements.forEach((element: Element) => {
const delay = element.getAttribute('delay');
const hide = () => element.classList.add('hidden');
if (delay) {
setTimeout(hide, parseInt(delay, 10));
} else {
hide();
}
});
};
const show: TurboStreamAction = function () {
this.targetElements.forEach((element: Element) => {
const delay = element.getAttribute('delay');
const show = () => element.classList.remove('hidden');
if (delay) {
setTimeout(show, parseInt(delay, 10));
} else {
show();
}
});
};
const focus: TurboStreamAction = function () {
this.targetElements.forEach((element: HTMLInputElement) => element.focus());
};
const disable: TurboStreamAction = function () {
this.targetElements.forEach((element: HTMLInputElement) => {
element.disabled = true;
});
};
const enable: TurboStreamAction = function () {
this.targetElements.forEach((element: HTMLInputElement) => {
element.disabled = false;
});
};
const morph: TurboStreamAction = function () {
this.targetElements.forEach((element: Element) => {
morphdom(element, this.templateContent, {
onBeforeElUpdated(fromEl, toEl) {
if (isTouchedInput(fromEl)) {
fromEl.removeAttribute('data-touched');
mergeInputValue(fromEl as HTMLInputElement, toEl as HTMLInputElement);
}
if (fromEl.isEqualNode(toEl)) {
return false;
}
return true;
}
});
});
};
const dispatch: TurboStreamAction = function () {
const type = this.getAttribute('event-type') ?? '';
const detail = this.getAttribute('event-detail');
const event = new CustomEvent(type, {
detail: JSON.parse(detail ?? '{}'),
bubbles: true
});
document.documentElement.dispatchEvent(event);
};
StreamActions['hide'] = hide;
StreamActions['show'] = show;
StreamActions['focus'] = focus;
StreamActions['disable'] = disable;
StreamActions['enable'] = enable;
StreamActions['morph'] = morph;
StreamActions['dispatch'] = dispatch;
function mergeInputValue(fromEl: HTMLInputElement, toEl: HTMLInputElement) {
toEl.value = fromEl.value;
toEl.checked = fromEl.checked;
}
function isTouchedInput(element: HTMLElement): boolean {
return (
['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName) &&
!!element.getAttribute('data-touched')
);
}

View file

@ -1,5 +0,0 @@
%turbo-event{ data: {
controller: 'turbo-event',
turbo_event_type_value: type,
turbo_event_detail_value: detail.to_json
} }

View file

@ -57,13 +57,3 @@
- if Rails.env.development?
= vite_typescript_tag 'axe-core'
= yield :charts_js
// Container for custom turbo-stream actions
%turbo-events
// We patch `@hotwired/turbo` to attach forms generated from links to this
// container instead of the body to avoid conflicts with `@rails/ujs`. We also
// patch `@hotwired/turbo` to add a timeout before removing the form because in
// order to be accepted as a valid `turbo form`` either global `turbo drive`` should
// be enabled or the form needs to have a parent with `data-turbo="true"` on it.
%div{ 'data-turbo': 'true' }

View file

@ -4,7 +4,7 @@
"@headlessui/react": "^1.6.6",
"@heroicons/react": "^1.0.6",
"@hotwired/stimulus": "^3.1.0",
"@hotwired/turbo": "^7.1.0",
"@hotwired/turbo": "^7.2.4",
"@mapbox/mapbox-gl-draw": "^1.3.0",
"@popperjs/core": "^2.11.6",
"@rails/actiontext": "^6.0.5",

View file

@ -1,50 +0,0 @@
diff --git a/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js b/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js
index 963422f..a4113bf 100644
--- a/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js
+++ b/node_modules/@hotwired/turbo/dist/turbo.es2017-esm.js
@@ -2609,7 +2609,7 @@ class Session {
const linkMethod = link.getAttribute("data-turbo-method");
if (linkMethod) {
const form = document.createElement("form");
- form.method = linkMethod;
+ form.setAttribute('method', linkMethod);
form.action = link.getAttribute("href") || "undefined";
form.hidden = true;
if (link.hasAttribute("data-turbo-confirm")) {
@@ -2621,9 +2621,9 @@ class Session {
form.addEventListener("turbo:submit-start", () => form.remove());
}
else {
- form.addEventListener("submit", () => form.remove());
+ form.addEventListener("submit", () => setTimeout(() => form.remove(), 500));
}
- document.body.appendChild(form);
+ (document.querySelector('body > [data-turbo="true"]') || document.body).appendChild(form);
return dispatch("submit", { cancelable: true, target: form });
}
else {
diff --git a/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js b/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js
index 101db1f..7d9cda6 100644
--- a/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js
+++ b/node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js
@@ -2615,7 +2615,7 @@ Copyright © 2021 Basecamp, LLC
const linkMethod = link.getAttribute("data-turbo-method");
if (linkMethod) {
const form = document.createElement("form");
- form.method = linkMethod;
+ form.setAttribute('method', linkMethod);
form.action = link.getAttribute("href") || "undefined";
form.hidden = true;
if (link.hasAttribute("data-turbo-confirm")) {
@@ -2627,9 +2627,9 @@ Copyright © 2021 Basecamp, LLC
form.addEventListener("turbo:submit-start", () => form.remove());
}
else {
- form.addEventListener("submit", () => form.remove());
+ form.addEventListener("submit", () => setTimeout(() => form.remove(), 500));
}
- document.body.appendChild(form);
+ (document.querySelector('body > [data-turbo="true"]') || document.body).appendChild(form);
return dispatch("submit", { cancelable: true, target: form });
}
else {

View file

@ -29,7 +29,7 @@ describe Champs::PieceJustificativeController, type: :controller do
it 'renders the attachment template as Javascript' do
subject
expect(response.status).to eq(200)
expect(response.body).to include("&quot;action&quot;:&quot;morph&quot;,&quot;target&quot;:&quot;#{champ.input_group_id}&quot;")
expect(response.body).to include("<turbo-stream action=\"morph\" target=\"#{champ.input_group_id}\">")
end
it 'updates dossier.last_champ_updated_at' do

View file

@ -349,10 +349,10 @@
resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.1.0.tgz#20215251e5afe6e0a3787285181ba1bfc9097df0"
integrity sha512-iDMHUhiEJ1xFeicyHcZQQgBzhtk5mPR0QZO3L6wtqzMsJEk2TKECuCQTGKjm+KJTHVY0dKq1dOOAWvODjpd2Mg==
"@hotwired/turbo@^7.1.0":
version "7.1.0"
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.1.0.tgz#27e44e0e3dc5bd1d4bda0766d579cf5a14091cd7"
integrity sha512-Q8kGjqwPqER+CtpQudbH+3Zgs2X4zb6pBAlr6NsKTXadg45pAOvxI9i4QpuHbwSzR2+x87HUm+rot9F/Pe8rxA==
"@hotwired/turbo@^7.2.4":
version "7.2.4"
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.2.4.tgz#0d35541be32cfae3b4f78c6ab9138f5b21f28a21"
integrity sha512-c3xlOroHp/cCZHDOuLp6uzQYEbvXBUVaal0puXoGJ9M8L/KHwZ3hQozD4dVeSN9msHWLxxtmPT1TlCN7gFhj4w==
"@humanwhocodes/config-array@^0.10.4":
version "0.10.4"