refactor(turbo): simplify custom actions implementation now that turbo has support for it
This commit is contained in:
parent
2e9f87a629
commit
85581e72e9
10 changed files with 128 additions and 223 deletions
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
}
|
|
@ -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';
|
||||
|
|
83
app/javascript/shared/turbo-actions.ts
Normal file
83
app/javascript/shared/turbo-actions.ts
Normal 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')
|
||||
);
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
%turbo-event{ data: {
|
||||
controller: 'turbo-event',
|
||||
turbo_event_type_value: type,
|
||||
turbo_event_detail_value: detail.to_json
|
||||
} }
|
|
@ -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' }
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
|
@ -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(""action":"morph","target":"#{champ.input_group_id}"")
|
||||
expect(response.body).to include("<turbo-stream action=\"morph\" target=\"#{champ.input_group_id}\">")
|
||||
end
|
||||
|
||||
it 'updates dossier.last_champ_updated_at' do
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue