Merge pull request #7175 from tchak/add-stimulus

refactor(stimulus): initiate react components via stimulus
This commit is contained in:
Paul Chavard 2022-04-20 14:15:14 +02:00 committed by GitHub
commit 4a9250f377
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 113 additions and 148 deletions

View file

@ -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'

View file

@ -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

View file

@ -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;
}

View file

@ -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] {

View file

@ -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] {

View file

@ -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] {

View file

@ -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)

View file

@ -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<string, unknown>;
type Loader = () => Promise<{ default: FunctionComponent<Props> }>;
const componentsRegistry = new Map<string, FunctionComponent<Props>>();
export function registerComponents(components: Record<string, Loader>): 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:
// <div data-controller="react" data-react-component-value="ComboMultiple" data-react-props-value="{}"></div>
//
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(
<StrictMode>
<Component {...props} />
</StrictMode>,
node
);
}
private getComponent(componentName: string): FunctionComponent<Props> | null {
return componentsRegistry.get(componentName) ?? null;
}
}
const Spinner = () => <div className="spinner left" />;
function LoadableComponent(loader: Loader): FunctionComponent<Props> {
const LazyComponent = lazy(loader);
const Component: FunctionComponent<Props> = (props: Props) => (
<Suspense fallback={<Spinner />}>
<LazyComponent {...props} />
</Suspense>
);
return Component;
}

View file

@ -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;

View file

@ -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<HTMLDivElement> {
const [selector, parent] = getSelector(searchSelector);
return parent.querySelectorAll<HTMLDivElement>(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<string, ComponentClass>) {
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 = () => <div className="spinner left" />;
export function Loadable(loader: () => Promise<{ default: ComponentClass }>) {
const LazyComponent = lazy(loader);
return function PureComponent(props: Record<string, unknown>) {
return (
<Suspense fallback={<Loader />}>
<LazyComponent {...props} />
</Suspense>
);
};
}
export function registerReactComponents(
components: Record<string, ComponentClass>
) {
const registry = new ReactComponentRegistry(components);
addEventListener('ds:page:update', () => registry.mountComponents());
}

View file

@ -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',
{

View file

@ -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",

View file

@ -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

View file

@ -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"