From 64c599e208d1e8bcd74b4d44ffdddae6a2cd6125 Mon Sep 17 00:00:00 2001 From: Paul Chavard Date: Wed, 20 Apr 2022 11:14:48 +0200 Subject: [PATCH] refactor(stimulus): initiate react components via stimulus --- Gemfile | 1 - Gemfile.lock | 13 --- app/assets/stylesheets/carte.scss | 2 +- app/assets/stylesheets/forms.scss | 4 +- .../stylesheets/personnes_impliquees.scss | 2 +- app/assets/stylesheets/procedure_show.scss | 2 +- app/helpers/application_helper.rb | 4 + .../controllers/react_controller.tsx | 70 +++++++++++++ app/javascript/packs/application.js | 57 +++++------ .../shared/register-react-components.tsx | 97 ------------------- babel.config.js | 1 + package.json | 1 + .../shared/dossiers/_edit.html.haml_spec.rb | 2 +- yarn.lock | 5 + 14 files changed, 113 insertions(+), 148 deletions(-) create mode 100644 app/javascript/controllers/react_controller.tsx delete mode 100644 app/javascript/shared/register-react-components.tsx diff --git a/Gemfile b/Gemfile index c6490706d..4d6db2cab 100644 --- a/Gemfile +++ b/Gemfile @@ -71,7 +71,6 @@ gem 'rack-attack' gem 'rails' gem 'rails-i18n' # Locales par défaut gem 'rake-progressbar', require: false -gem 'react-rails' gem 'rexml' # add missing gem due to ruby3 (https://github.com/Shopify/bootsnap/issues/325) gem 'rgeo-geojson' gem 'rqrcode' diff --git a/Gemfile.lock b/Gemfile.lock index 3cbe88b6e..ae2bd915b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -121,10 +121,6 @@ GEM axlsx_styler (1.1.0) activesupport (>= 3.1) caxlsx (>= 2.0.2) - babel-source (5.8.35) - babel-transpiler (0.7.0) - babel-source (>= 4.0, < 6) - execjs (~> 2.0) bcrypt (3.1.16) better_html (1.0.16) actionview (>= 4.0) @@ -173,7 +169,6 @@ GEM coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.1.10) - connection_pool (2.2.3) content_disposition (1.0.0) crack (0.4.5) rexml @@ -238,7 +233,6 @@ GEM ethon (0.15.0) ffi (>= 1.15.0) excon (0.79.0) - execjs (2.7.0) factory_bot (6.1.0) activesupport (>= 5.0.0) faraday (1.8.0) @@ -572,12 +566,6 @@ GEM rb-fsevent (0.10.4) rb-inotify (0.10.1) ffi (~> 1.0) - react-rails (2.6.1) - babel-transpiler (>= 0.7.0) - connection_pool - execjs - railties (>= 3.2) - tilt regexp_parser (2.1.0) request_store (1.5.0) rack (>= 1.4) @@ -885,7 +873,6 @@ DEPENDENCIES rails-erd rails-i18n rake-progressbar - react-rails rexml rgeo-geojson rqrcode diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index 46809bd99..fa9f9e067 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -15,7 +15,7 @@ } } -.form [data-react-class='MapEditor'] [data-reach-combobox-input] { +.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] { margin-bottom: 0; } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 596c1e2dd..71b07ae2f 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -488,13 +488,13 @@ } } -[data-react-class]:not([data-react-class^="ComboMultiple"]) { +[data-react-component-value]:not([data-react-component-value^="ComboMultiple"]) { [data-reach-combobox-input]:not(.no-margin) { margin-bottom: $default-fields-spacer; } } -[data-react-class^="ComboMultiple"] { +[data-react-component-value^="ComboMultiple"] { margin-bottom: $default-fields-spacer; [data-reach-combobox-input] { diff --git a/app/assets/stylesheets/personnes_impliquees.scss b/app/assets/stylesheets/personnes_impliquees.scss index 7be4fb89e..5fb6ae3fc 100644 --- a/app/assets/stylesheets/personnes_impliquees.scss +++ b/app/assets/stylesheets/personnes_impliquees.scss @@ -9,7 +9,7 @@ margin-left: 16px; } - [data-react-class^="ComboMultiple"] { + [data-react-component-value^="ComboMultiple"] { margin-bottom: $default-fields-spacer; [data-reach-combobox-token-list] { diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss index 182be6324..c37894953 100644 --- a/app/assets/stylesheets/procedure_show.scss +++ b/app/assets/stylesheets/procedure_show.scss @@ -62,7 +62,7 @@ text-align: center; } - [data-react-class^="ComboMultiple"] { + [data-react-component-value^="ComboMultiple"] { margin-bottom: $default-fields-spacer; [data-reach-combobox-token-list] { diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e5ee529a3..c1c6999a6 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -26,6 +26,10 @@ module ApplicationHelper class_names.join(' ') end + def react_component(name, props = {}, html = {}) + tag.div(**html.merge(data: { controller: 'react', react_component_value: name, react_props_value: props.to_json })) + end + def render_to_element(selector, partial:, outer: false, locals: {}) method = outer ? 'outerHTML' : 'innerHTML' html = escape_javascript(render partial: partial, locals: locals) diff --git a/app/javascript/controllers/react_controller.tsx b/app/javascript/controllers/react_controller.tsx new file mode 100644 index 000000000..0e30b8a08 --- /dev/null +++ b/app/javascript/controllers/react_controller.tsx @@ -0,0 +1,70 @@ +import { Controller } from '@hotwired/stimulus'; +import React, { lazy, Suspense, FunctionComponent, StrictMode } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import invariant from 'tiny-invariant'; + +type Props = Record; +type Loader = () => Promise<{ default: FunctionComponent }>; +const componentsRegistry = new Map>(); + +export function registerComponents(components: Record): void { + for (const [className, loader] of Object.entries(components)) { + componentsRegistry.set(className, LoadableComponent(loader)); + } +} + +// Initialize React components when their markup appears into the DOM. +// +// Example: +//
+// +export class ReactController extends Controller { + static values = { + component: String, + props: Object + }; + + declare readonly componentValue: string; + declare readonly propsValue: Props; + + connect(): void { + this.mountComponent(this.element as HTMLElement); + } + + disconnect(): void { + unmountComponentAtNode(this.element as HTMLElement); + } + + private mountComponent(node: HTMLElement): void { + const componentName = this.componentValue; + const props = this.propsValue; + const Component = this.getComponent(componentName); + + invariant( + Component, + `Cannot find a React component with class "${componentName}"` + ); + render( + + + , + node + ); + } + + private getComponent(componentName: string): FunctionComponent | null { + return componentsRegistry.get(componentName) ?? null; + } +} + +const Spinner = () =>
; + +function LoadableComponent(loader: Loader): FunctionComponent { + const LazyComponent = lazy(loader); + const Component: FunctionComponent = (props: Props) => ( + }> + + + ); + return Component; +} diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 22e0f38d9..89b5c3e7a 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -2,6 +2,7 @@ import '../shared/polyfills'; import Rails from '@rails/ujs'; import * as ActiveStorage from '@rails/activestorage'; import 'whatwg-fetch'; // window.fetch polyfill +import { Application } from '@hotwired/stimulus'; import '../shared/page-update-event'; import '../shared/activestorage/ujs'; @@ -12,6 +13,11 @@ import '../shared/franceconnect'; import '../shared/toggle-target'; import '../shared/ujs-error-handling'; +import { + ReactController, + registerComponents +} from '../controllers/react_controller'; + import '../new_design/dropdown'; import '../new_design/form-validation'; import '../new_design/procedure-context'; @@ -46,37 +52,23 @@ import { showNewAccountPasswordConfirmation } from '../new_design/fc-fusion'; -import { - registerReactComponents, - Loadable -} from '../shared/register-react-components'; - -registerReactComponents({ - Chartkick: Loadable(() => import('../components/Chartkick')), - ComboAdresseSearch: Loadable(() => - import('../components/ComboAdresseSearch') - ), - ComboAnnuaireEducationSearch: Loadable(() => - import('../components/ComboAnnuaireEducationSearch') - ), - ComboCommunesSearch: Loadable(() => - import('../components/ComboCommunesSearch') - ), - ComboDepartementsSearch: Loadable(() => - import('../components/ComboDepartementsSearch') - ), - ComboMultipleDropdownList: Loadable(() => - import('../components/ComboMultipleDropdownList') - ), - ComboMultiple: Loadable(() => import('../components/ComboMultiple')), - ComboPaysSearch: Loadable(() => import('../components/ComboPaysSearch')), - ComboRegionsSearch: Loadable(() => - import('../components/ComboRegionsSearch') - ), - MapEditor: Loadable(() => import('../components/MapEditor')), - MapReader: Loadable(() => import('../components/MapReader')), - Trix: Loadable(() => import('../components/Trix')), - TypesDeChampEditor: Loadable(() => import('../components/TypesDeChampEditor')) +registerComponents({ + Chartkick: () => import('../components/Chartkick'), + ComboAdresseSearch: () => import('../components/ComboAdresseSearch'), + ComboAnnuaireEducationSearch: () => + import('../components/ComboAnnuaireEducationSearch'), + ComboCommunesSearch: () => import('../components/ComboCommunesSearch'), + ComboDepartementsSearch: () => + import('../components/ComboDepartementsSearch'), + ComboMultipleDropdownList: () => + import('../components/ComboMultipleDropdownList'), + ComboMultiple: () => import('../components/ComboMultiple'), + ComboPaysSearch: () => import('../components/ComboPaysSearch'), + ComboRegionsSearch: () => import('../components/ComboRegionsSearch'), + MapEditor: () => import('../components/MapEditor'), + MapReader: () => import('../components/MapReader'), + Trix: () => import('../components/Trix'), + TypesDeChampEditor: () => import('../components/TypesDeChampEditor') }); // This is the global application namespace where we expose helpers used from rails views @@ -98,5 +90,8 @@ const DS = { Rails.start(); ActiveStorage.start(); +const Stimulus = Application.start(); +Stimulus.register('react', ReactController); + // Expose globals window.DS = window.DS || DS; diff --git a/app/javascript/shared/register-react-components.tsx b/app/javascript/shared/register-react-components.tsx deleted file mode 100644 index 48bd43526..000000000 --- a/app/javascript/shared/register-react-components.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { Suspense, lazy, createElement, ComponentClass } from 'react'; -import { render } from 'react-dom'; - -// This attribute holds the name of component which should be mounted -// example: `data-react-class="MyApp.Items.EditForm"` -const CLASS_NAME_ATTR = 'data-react-class'; - -// This attribute holds JSON stringified props for initializing the component -// example: `data-react-props="{\"item\": { \"id\": 1, \"name\": \"My Item\"} }"` -const PROPS_ATTR = 'data-react-props'; - -const CLASS_NAME_SELECTOR = `[${CLASS_NAME_ATTR}]`; - -// helper method for the mount and unmount methods to find the -// `data-react-class` DOM elements -function findDOMNodes(searchSelector?: string): NodeListOf { - const [selector, parent] = getSelector(searchSelector); - return parent.querySelectorAll(selector); -} - -function getSelector(searchSelector?: string): [string, Document] { - switch (typeof searchSelector) { - case 'undefined': - return [CLASS_NAME_SELECTOR, document]; - case 'object': - return [CLASS_NAME_SELECTOR, searchSelector]; - case 'string': - return [ - ['', ' '] - .map( - (separator) => `${searchSelector}${separator}${CLASS_NAME_SELECTOR}` - ) - .join(', '), - document - ]; - } -} - -class ReactComponentRegistry { - #components; - - constructor(components: Record) { - this.#components = components; - } - - getConstructor(className: string | null) { - return className ? this.#components[className] : null; - } - - mountComponents(searchSelector?: string) { - const nodes = findDOMNodes(searchSelector); - - for (const node of nodes) { - const className = node.getAttribute(CLASS_NAME_ATTR); - const ComponentClass = this.getConstructor(className); - const propsJson = node.getAttribute(PROPS_ATTR); - const props = propsJson && JSON.parse(propsJson); - - if (!ComponentClass) { - const message = `Cannot find component: "${className}"`; - console?.log( - `%c[react-rails] %c${message} for element`, - 'font-weight: bold', - '', - node - ); - throw new Error( - `${message}. Make sure your component is available to render.` - ); - } else { - render(createElement(ComponentClass, props), node); - } - } - } -} - -const Loader = () =>
; - -export function Loadable(loader: () => Promise<{ default: ComponentClass }>) { - const LazyComponent = lazy(loader); - - return function PureComponent(props: Record) { - return ( - }> - - - ); - }; -} - -export function registerReactComponents( - components: Record -) { - const registry = new ReactComponentRegistry(components); - - addEventListener('ds:page:update', () => registry.mountComponents()); -} diff --git a/babel.config.js b/babel.config.js index f236e2bf6..92513dd6a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -49,6 +49,7 @@ module.exports = function (api) { '@babel/plugin-syntax-dynamic-import', isTestEnv && 'babel-plugin-dynamic-import-node', '@babel/plugin-transform-destructuring', + ['@babel/plugin-transform-typescript', { allowDeclareFields: true }], [ '@babel/plugin-proposal-class-properties', { diff --git a/package.json b/package.json index c53e16e27..1b064f9d4 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "@babel/preset-typescript": "^7.16.7", "@headlessui/react": "^1.5.0", "@heroicons/react": "^1.0.6", + "@hotwired/stimulus": "^3.0.1", "@mapbox/mapbox-gl-draw": "^1.3.0", "@popperjs/core": "^2.11.4", "@rails/actiontext": "^6.1.4-1", diff --git a/spec/views/shared/dossiers/_edit.html.haml_spec.rb b/spec/views/shared/dossiers/_edit.html.haml_spec.rb index 2a3b072ad..52dc5d143 100644 --- a/spec/views/shared/dossiers/_edit.html.haml_spec.rb +++ b/spec/views/shared/dossiers/_edit.html.haml_spec.rb @@ -87,7 +87,7 @@ describe 'shared/dossiers/edit.html.haml', type: :view do let(:champ_value) { ['banana', 'grapefruit'].to_json } it 'renders the list as a multiple-selection dropdown' do - expect(subject).to have_selector('[data-react-class="ComboMultipleDropdownList"]') + expect(subject).to have_selector('[data-react-component-value="ComboMultipleDropdownList"]') end end end diff --git a/yarn.lock b/yarn.lock index ab3172abe..f47219e4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1269,6 +1269,11 @@ resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-1.0.6.tgz#35dd26987228b39ef2316db3b1245c42eb19e324" integrity sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ== +"@hotwired/stimulus@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.0.1.tgz#141f15645acaa3b133b7c247cad58ae252ffae85" + integrity sha512-oHsJhgY2cip+K2ED7vKUNd2P+BEswVhrCYcJ802DSsblJFv7mPFVk3cQKvm2vHgHeDVdnj7oOKrBbzp1u8D+KA== + "@humanwhocodes/config-array@^0.5.0": version "0.5.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"