diff --git a/app/helpers/turbo_stream_helper.rb b/app/helpers/turbo_stream_helper.rb index b62c293ff..fac4423a6 100644 --- a/app/helpers/turbo_stream_helper.rb +++ b/app/helpers/turbo_stream_helper.rb @@ -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 diff --git a/app/javascript/controllers/turbo_event_controller.ts b/app/javascript/controllers/turbo_event_controller.ts deleted file mode 100644 index b8741e799..000000000 --- a/app/javascript/controllers/turbo_event_controller.ts +++ /dev/null @@ -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; -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; - -addEventListener('dom:mutation', (event) => { - const detail = (event as CustomEvent).detail; - const mutation = Mutation.parse(detail); - mutate(mutation); -}); - -const Mutations: Record 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(mutation)) { - element.disabled = true; - } - }, - enable: (mutation) => { - for (const element of findElements(mutation)) { - element.disabled = false; - } - }, - morph: (mutation) => { - invariant(mutation.html, 'morph action requires html'); - for (const element of findElements(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( - mutation: Mutation -): Element[] { - if ('target' in mutation) { - const element = document.querySelector(`#${mutation.target}`); - invariant(element, `Could not find element with id ${mutation.target}`); - return [element]; - } else if ('targets' in mutation) { - return [...document.querySelectorAll(mutation.targets)]; - } - invariant(false, 'Could not find element'); -} diff --git a/app/javascript/entrypoints/application.js b/app/javascript/entrypoints/application.js index 3143e2dd9..404fba434 100644 --- a/app/javascript/entrypoints/application.js +++ b/app/javascript/entrypoints/application.js @@ -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'; diff --git a/app/javascript/shared/turbo-actions.ts b/app/javascript/shared/turbo-actions.ts new file mode 100644 index 000000000..124857786 --- /dev/null +++ b/app/javascript/shared/turbo-actions.ts @@ -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') + ); +} diff --git a/app/views/layouts/_turbo_event.html.haml b/app/views/layouts/_turbo_event.html.haml deleted file mode 100644 index e7ddff930..000000000 --- a/app/views/layouts/_turbo_event.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%turbo-event{ data: { - controller: 'turbo-event', - turbo_event_type_value: type, - turbo_event_detail_value: detail.to_json -} } diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 742c6e85a..199332ada 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -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' } diff --git a/package.json b/package.json index e27f0e6f6..d541e038c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/patches/@hotwired+turbo+7.1.0.patch b/patches/@hotwired+turbo+7.1.0.patch deleted file mode 100644 index 8b1a5ed6c..000000000 --- a/patches/@hotwired+turbo+7.1.0.patch +++ /dev/null @@ -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 { diff --git a/spec/controllers/champs/piece_justificative_controller_spec.rb b/spec/controllers/champs/piece_justificative_controller_spec.rb index d806ad19f..de416ce83 100644 --- a/spec/controllers/champs/piece_justificative_controller_spec.rb +++ b/spec/controllers/champs/piece_justificative_controller_spec.rb @@ -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("") end it 'updates dossier.last_champ_updated_at' do diff --git a/yarn.lock b/yarn.lock index 17f92bac1..aa732adb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"