Merge pull request #7175 from tchak/add-stimulus
refactor(stimulus): initiate react components via stimulus
This commit is contained in:
commit
4a9250f377
14 changed files with 113 additions and 148 deletions
1
Gemfile
1
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'
|
||||
|
|
13
Gemfile.lock
13
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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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] {
|
||||
|
|
|
@ -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)
|
||||
|
|
70
app/javascript/controllers/react_controller.tsx
Normal file
70
app/javascript/controllers/react_controller.tsx
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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());
|
||||
}
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue