Merge pull request #7897 from tchak/feat-better-turbo-actions
refactor(turbo): simplify custom actions implementation now that turbo has support for it
This commit is contained in:
commit
4f30581962
10 changed files with 128 additions and 223 deletions
|
@ -4,50 +4,70 @@ module TurboStreamHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
class TagBuilder < Turbo::Streams::TagBuilder
|
class TagBuilder < Turbo::Streams::TagBuilder
|
||||||
def dispatch(type, detail = {})
|
include ActionView::Helpers::TagHelper
|
||||||
append_all('turbo-events', partial: 'layouts/turbo_event', locals: { type: type, detail: detail })
|
|
||||||
end
|
|
||||||
|
|
||||||
def show(target, delay: nil)
|
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
|
end
|
||||||
|
|
||||||
def show_all(targets, delay: nil)
|
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
|
end
|
||||||
|
|
||||||
def hide(target, delay: nil)
|
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
|
end
|
||||||
|
|
||||||
def hide_all(targets, delay: nil)
|
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
|
end
|
||||||
|
|
||||||
def focus(target)
|
def focus(target)
|
||||||
dispatch('dom:mutation', { action: :focus, target: target })
|
turbo_stream_simple_action_tag :focus, target: target
|
||||||
end
|
end
|
||||||
|
|
||||||
def focus_all(targets)
|
def focus_all(targets)
|
||||||
dispatch('dom:mutation', { action: :focus, targets: targets })
|
turbo_stream_simple_action_tag :focus, targets: targets
|
||||||
end
|
|
||||||
|
|
||||||
def disable(target)
|
|
||||||
dispatch('dom:mutation', { action: :disable, target: target })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable(target)
|
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
|
end
|
||||||
|
|
||||||
def morph(target, content = nil, **rendering, &block)
|
def morph(target, content = nil, **rendering, &block)
|
||||||
template = render_template(target, content, allow_inferred_rendering: true, **rendering, &block)
|
action :morph, target, content, **rendering, &block
|
||||||
dispatch('dom:mutation', { action: :morph, target: target, html: template })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def morph_all(targets, content = nil, **rendering, &block)
|
def morph_all(targets, content = nil, **rendering, &block)
|
||||||
template = render_template(targets, content, allow_inferred_rendering: true, **rendering, &block)
|
action_all :morph, targets, content, **rendering, &block
|
||||||
dispatch('dom:mutation', { action: :morph, targets: targets, html: template })
|
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
|
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 { Application } from '@hotwired/stimulus';
|
||||||
import '@gouvfr/dsfr/dist/dsfr.module.js';
|
import '@gouvfr/dsfr/dist/dsfr.module.js';
|
||||||
|
|
||||||
|
import '../shared/turbo-actions';
|
||||||
import '../shared/activestorage/ujs';
|
import '../shared/activestorage/ujs';
|
||||||
import '../shared/safari-11-empty-file-workaround';
|
import '../shared/safari-11-empty-file-workaround';
|
||||||
import '../shared/toggle-target';
|
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?
|
- if Rails.env.development?
|
||||||
= vite_typescript_tag 'axe-core'
|
= vite_typescript_tag 'axe-core'
|
||||||
= yield :charts_js
|
= 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",
|
"@headlessui/react": "^1.6.6",
|
||||||
"@heroicons/react": "^1.0.6",
|
"@heroicons/react": "^1.0.6",
|
||||||
"@hotwired/stimulus": "^3.1.0",
|
"@hotwired/stimulus": "^3.1.0",
|
||||||
"@hotwired/turbo": "^7.1.0",
|
"@hotwired/turbo": "^7.2.4",
|
||||||
"@mapbox/mapbox-gl-draw": "^1.3.0",
|
"@mapbox/mapbox-gl-draw": "^1.3.0",
|
||||||
"@popperjs/core": "^2.11.6",
|
"@popperjs/core": "^2.11.6",
|
||||||
"@rails/actiontext": "^6.0.5",
|
"@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
|
it 'renders the attachment template as Javascript' do
|
||||||
subject
|
subject
|
||||||
expect(response.status).to eq(200)
|
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
|
end
|
||||||
|
|
||||||
it 'updates dossier.last_champ_updated_at' do
|
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"
|
resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.1.0.tgz#20215251e5afe6e0a3787285181ba1bfc9097df0"
|
||||||
integrity sha512-iDMHUhiEJ1xFeicyHcZQQgBzhtk5mPR0QZO3L6wtqzMsJEk2TKECuCQTGKjm+KJTHVY0dKq1dOOAWvODjpd2Mg==
|
integrity sha512-iDMHUhiEJ1xFeicyHcZQQgBzhtk5mPR0QZO3L6wtqzMsJEk2TKECuCQTGKjm+KJTHVY0dKq1dOOAWvODjpd2Mg==
|
||||||
|
|
||||||
"@hotwired/turbo@^7.1.0":
|
"@hotwired/turbo@^7.2.4":
|
||||||
version "7.1.0"
|
version "7.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.1.0.tgz#27e44e0e3dc5bd1d4bda0766d579cf5a14091cd7"
|
resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.2.4.tgz#0d35541be32cfae3b4f78c6ab9138f5b21f28a21"
|
||||||
integrity sha512-Q8kGjqwPqER+CtpQudbH+3Zgs2X4zb6pBAlr6NsKTXadg45pAOvxI9i4QpuHbwSzR2+x87HUm+rot9F/Pe8rxA==
|
integrity sha512-c3xlOroHp/cCZHDOuLp6uzQYEbvXBUVaal0puXoGJ9M8L/KHwZ3hQozD4dVeSN9msHWLxxtmPT1TlCN7gFhj4w==
|
||||||
|
|
||||||
"@humanwhocodes/config-array@^0.10.4":
|
"@humanwhocodes/config-array@^0.10.4":
|
||||||
version "0.10.4"
|
version "0.10.4"
|
||||||
|
|
Loading…
Reference in a new issue