From 7f9414012e05ee58eed17e92bd7725b78e8e785a Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Fri, 13 Jan 2023 11:13:09 +0100 Subject: [PATCH] refactor(turbo): use @coldwired/actions --- app/components/attachment/edit_component.rb | 3 +- .../repetition_row_component.html.haml | 2 +- app/helpers/turbo_stream_helper.rb | 5 +- .../controllers/turbo_controller.ts | 79 +++++++++++++--- app/javascript/entrypoints/application.js | 1 - app/javascript/entrypoints/manager.ts | 5 +- app/javascript/shared/turbo-actions.ts | 94 ------------------- package.json | 3 +- yarn.lock | 23 +++++ 9 files changed, 100 insertions(+), 115 deletions(-) delete mode 100644 app/javascript/shared/turbo-actions.ts diff --git a/app/components/attachment/edit_component.rb b/app/components/attachment/edit_component.rb index 3cd253b22..d21199de5 100644 --- a/app/components/attachment/edit_component.rb +++ b/app/components/attachment/edit_component.rb @@ -72,7 +72,8 @@ class Attachment::EditComponent < ApplicationComponent id: input_id, aria: { describedby: champ&.describedby_id }, data: { - auto_attach_url: + auto_attach_url:, + turbo_force: true }.merge(has_file_size_validator? ? { max_file_size: } : {}) .merge(user_can_replace? ? { replace_attachment_target: "input" } : {}) }.merge(has_content_type_validator? ? { accept: accept_content_type } : {}) diff --git a/app/components/editable_champ/repetition_row_component/repetition_row_component.html.haml b/app/components/editable_champ/repetition_row_component/repetition_row_component.html.haml index 46f7074bd..948bc0dac 100644 --- a/app/components/editable_champ/repetition_row_component/repetition_row_component.html.haml +++ b/app/components/editable_champ/repetition_row_component/repetition_row_component.html.haml @@ -1,4 +1,4 @@ -- row_id = @row.first.row_id +- row_id = "row-#{@row.first.row_id}" .row{ id: row_id } - @row.each do |champ| = fields_for champ.input_name, champ do |form| diff --git a/app/helpers/turbo_stream_helper.rb b/app/helpers/turbo_stream_helper.rb index fac4423a6..11095adae 100644 --- a/app/helpers/turbo_stream_helper.rb +++ b/app/helpers/turbo_stream_helper.rb @@ -54,8 +54,9 @@ module TurboStreamHelper 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) + def dispatch(type, detail = nil) + content = detail.present? ? tag.script(cdata_section(detail.to_json), type: 'application/json') : nil + action_all :append, 'head', tag.dispatch_event(content, type:) end private diff --git a/app/javascript/controllers/turbo_controller.ts b/app/javascript/controllers/turbo_controller.ts index 730fcd4ff..419686c1c 100644 --- a/app/javascript/controllers/turbo_controller.ts +++ b/app/javascript/controllers/turbo_controller.ts @@ -1,40 +1,91 @@ -import { show, hide } from '@utils'; -import { session as TurboSession } from '@hotwired/turbo'; +import { Actions } from '@coldwired/actions'; +import { parseTurboStream } from '@coldwired/turbo-stream'; +import invariant from 'tiny-invariant'; +import { session as TurboSession, type StreamElement } from '@hotwired/turbo'; import { ApplicationController } from './application_controller'; +type StreamRenderEvent = CustomEvent<{ + render(streamElement: StreamElement): void; +}>; + export class TurboController extends ApplicationController { static targets = ['spinner']; - declare readonly spinnerTarget: HTMLElement; - declare readonly hasSpinnerTarget: boolean; + declare readonly spinnerTargets: HTMLElement[]; - #submitting = true; + #submitting = false; + #actions?: Actions; + + // `actions` instrface exposes all available actions as methods and also `applyActions` method + // wich allows to apply a batch of actions. On top of regular `turbo-stream` actions we also + // expose `focus`, `enable`, `disable`, `show` and `hide` actions. Each action take a `targets` + // option (wich can be a CSS selector or a list of DOM nodes) and a `fragment` option (wich is a + // `DocumentFragment` and only required on "rendering" actions). + get actions() { + invariant(this.#actions, 'Actions not initialized'); + return this.#actions; + } connect() { + this.#actions = new Actions({ + element: document.documentElement, + schema: { forceAttribute: 'data-turbo-force', hiddenClassName: 'hidden' } + }); + + // actions#observe() is an interface over specialized mutation observers. + // They allow us to preserve certain HTML changes across mutations. + this.#actions.observe(); + + // setup spinner events this.onGlobal('turbo:submit-start', () => this.startSpinner()); this.onGlobal('turbo:submit-end', () => this.stopSpinner()); this.onGlobal('turbo:fetch-request-error', () => this.stopSpinner()); // prevent scroll on turbo form submits this.onGlobal('turbo:render', () => this.preventScrollIfNeeded()); + + // reset state preserved for actions between pages + this.onGlobal('turbo:load', () => this.actions.reset()); + + // see: https://turbo.hotwired.dev/handbook/streams#custom-actions + this.onGlobal('turbo:before-stream-render', (event: StreamRenderEvent) => { + const fallbackToDefaultActions = event.detail.render; + event.detail.render = (streamElement: StreamElement) => + this.renderStreamElement(streamElement, fallbackToDefaultActions); + }); } - startSpinner() { + private renderStreamElement( + streamElement: StreamElement, + fallbackRender: (streamElement: StreamElement) => void + ) { + switch (streamElement.action) { + // keep turbo default behavior to avoid risks going all in on coldwire + case 'replace': + case 'update': + fallbackRender(streamElement); + break; + case 'morph': + streamElement.setAttribute('action', 'replace'); + this.actions.applyActions([parseTurboStream(streamElement)]); + break; + default: + this.actions.applyActions([parseTurboStream(streamElement)]); + } + } + + private startSpinner() { this.#submitting = true; - if (this.hasSpinnerTarget) { - show(this.spinnerTarget); - } + this.actions.show({ targets: this.spinnerTargets }); } - stopSpinner() { + private stopSpinner() { this.#submitting = false; - if (this.hasSpinnerTarget) { - hide(this.spinnerTarget); - } + this.actions.hide({ targets: this.spinnerTargets }); } - preventScrollIfNeeded() { + private preventScrollIfNeeded() { if (this.#submitting && TurboSession.navigator.currentVisit) { TurboSession.navigator.currentVisit.scrolled = true; } diff --git a/app/javascript/entrypoints/application.js b/app/javascript/entrypoints/application.js index cec0372c2..87e34ffbd 100644 --- a/app/javascript/entrypoints/application.js +++ b/app/javascript/entrypoints/application.js @@ -4,7 +4,6 @@ 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/entrypoints/manager.ts b/app/javascript/entrypoints/manager.ts index a4981c44e..2ce8cd885 100644 --- a/app/javascript/entrypoints/manager.ts +++ b/app/javascript/entrypoints/manager.ts @@ -1,7 +1,6 @@ import * as Turbo from '@hotwired/turbo'; import { Application } from '@hotwired/stimulus'; -import '../shared/turbo-actions'; import '../manager/fields/features'; import { registerControllers } from '../shared/stimulus-loader'; @@ -9,3 +8,7 @@ const application = Application.start(); registerControllers(application); Turbo.session.drive = false; + +addEventListener('DOMContentLoaded', () => { + document.body.setAttribute('data-controller', 'turbo'); +}); diff --git a/app/javascript/shared/turbo-actions.ts b/app/javascript/shared/turbo-actions.ts deleted file mode 100644 index bb10d756f..000000000 --- a/app/javascript/shared/turbo-actions.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { StreamActions, TurboStreamAction } from '@hotwired/turbo'; -import morphdom from 'morphdom'; - -const hide: TurboStreamAction = function () { - this.targetElements.forEach((element: Element) => { - const delay = this.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 = this.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) => { - let content: Element | DocumentFragment = this.templateContent; - - // content.children ignores text node, the empty text nodes in particular - // so if templateContent contains an empty text node, - // we only keep the first element and happily morph it - if (content.children.length == 1) { - content = content.children[0]; - } - - // morphom morphes if content is an element - // swaps if content if a documentFragment - morphdom(element, content, { - onBeforeElUpdated: function (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/package.json b/package.json index 874350758..d2882b7eb 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,7 @@ { "dependencies": { + "@coldwired/actions": "^0.8.0", + "@coldwired/turbo-stream": "^0.8.0", "@gouvfr/dsfr": "^1.7.2", "@graphiql/plugin-explorer": "^0.1.11", "@graphiql/toolkit": "^0.8.0", @@ -30,7 +32,6 @@ "is-hotkey": "^0.2.0", "maplibre-gl": "^1.15.2", "match-sorter": "^6.2.0", - "morphdom": "^2.6.1", "patch-package": "^6.5.1", "react": "^18.2.0", "react-coordinate-input": "^1.0.0", diff --git a/yarn.lock b/yarn.lock index 2760b8494..9c3e54afb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -511,6 +511,29 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@coldwired/actions@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@coldwired/actions/-/actions-0.8.0.tgz#a5e92c0badbc37ffa7c9e70d2aa7982739fbba5f" + integrity sha512-IbqyC2ToDv1JWBVkk33ywDrftD34r3WBLokub7Tlon8xS6FQQVejwmgqIqiQ3iLE0jiXOqKz2an3Y1e69mJ55A== + dependencies: + "@coldwired/utils" "^0.4.1" + morphdom "^2.6.1" + tiny-invariant "^1.3.1" + +"@coldwired/turbo-stream@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@coldwired/turbo-stream/-/turbo-stream-0.8.0.tgz#596202ecbca88f9b264d5055bedcb94298db626b" + integrity sha512-/ZKdAcgDkcH+YkW2RyxXGMM6OdpKp4nVpFGTYnor+4mR/jjEdnTRw+3wnZJQfgPzLMG39SXEmY4mp9xQM/qA3Q== + dependencies: + "@coldwired/actions" "^0.8.0" + "@coldwired/utils" "^0.4.1" + tiny-invariant "^1.3.1" + +"@coldwired/utils@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@coldwired/utils/-/utils-0.4.1.tgz#50a615c81948754f4c7f118df05a44e078f28568" + integrity sha512-a673URA77AUBm8u5bWGtEmMi9oMv8HBidPT1GtRsaYma1W23Hd2Emn+oD6vUyGLfL+hJGT91w+Y2Y3x9BgLqwQ== + "@esbuild/android-arm64@0.16.14": version "0.16.14" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.16.14.tgz#f02c9f0d43086ddf6ed2795b881ddf7990f74456"