diff --git a/app/assets/stylesheets/01_common.scss b/app/assets/stylesheets/01_common.scss index 00c777a0c..47b94bc1d 100644 --- a/app/assets/stylesheets/01_common.scss +++ b/app/assets/stylesheets/01_common.scss @@ -28,3 +28,7 @@ body { .container { @extend %container; } + +react-fragment { + display: block; +} diff --git a/app/assets/stylesheets/carte.scss b/app/assets/stylesheets/carte.scss index ec8dd3c48..3de591ed2 100644 --- a/app/assets/stylesheets/carte.scss +++ b/app/assets/stylesheets/carte.scss @@ -10,7 +10,7 @@ } } -.form [data-react-component-value='MapEditor'] [data-reach-combobox-input] { +.form react-fragment[data-component-name='MapEditor'] [data-reach-combobox-input] { margin-bottom: 0; } diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index 7b0f484cf..3b144f941 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -524,7 +524,7 @@ } } -[data-react-component-value^="ComboMultiple"] { +react-fragment[data-component-name^="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 d47aa2755..990876d80 100644 --- a/app/assets/stylesheets/personnes_impliquees.scss +++ b/app/assets/stylesheets/personnes_impliquees.scss @@ -9,7 +9,7 @@ margin-left: 16px; } - [data-react-component-value^="ComboMultiple"] { + react-fragment[data-component-name^="ComboMultiple"] { margin-bottom: 0; [data-reach-combobox-token-list] { diff --git a/app/assets/stylesheets/procedure_show.scss b/app/assets/stylesheets/procedure_show.scss index 5100162cb..8b27c3436 100644 --- a/app/assets/stylesheets/procedure_show.scss +++ b/app/assets/stylesheets/procedure_show.scss @@ -45,7 +45,7 @@ display: inline-block; } - [data-react-component-value^="ComboMultiple"] { + react-fragment[data-component-name^="ComboMultiple"] { margin-bottom: $default-fields-spacer; [data-reach-combobox-token-list] { diff --git a/app/components/react_component.rb b/app/components/react_component.rb new file mode 100644 index 000000000..0f643d5ec --- /dev/null +++ b/app/components/react_component.rb @@ -0,0 +1,14 @@ +class ReactComponent < ApplicationComponent + erb_template <<-ERB + <% if content? %> + props="<%= @props.to_json %>"><%= content %> + <% else %> + props="<%= @props.to_json %>"> + <% end %> + ERB + + def initialize(name, **props) + @name = name + @props = props + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f5f859032..856cc923e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -59,10 +59,6 @@ module ApplicationHelper 'alert' 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 current_email current_user&.email || current_instructeur&.email || diff --git a/app/javascript/components/ComboMultiple.tsx b/app/javascript/components/ComboMultiple.tsx index 713e64a8c..a4206b9f8 100644 --- a/app/javascript/components/ComboMultiple.tsx +++ b/app/javascript/components/ComboMultiple.tsx @@ -1,4 +1,4 @@ -import React, { +import { useMemo, useState, useRef, diff --git a/app/javascript/components/Layout.tsx b/app/javascript/components/Layout.tsx new file mode 100644 index 000000000..39a33aa9e --- /dev/null +++ b/app/javascript/components/Layout.tsx @@ -0,0 +1,12 @@ +import { I18nProvider } from 'react-aria-components'; +import { StrictMode, type ReactNode } from 'react'; + +export function Layout({ children }: { children: ReactNode }) { + const locale = document.documentElement.lang; + console.debug(`locale: ${locale}`); + return ( + + {children} + + ); +} diff --git a/app/javascript/components/MapEditor/components/ImportFileInput.tsx b/app/javascript/components/MapEditor/components/ImportFileInput.tsx index 68be216e2..28122dba5 100644 --- a/app/javascript/components/MapEditor/components/ImportFileInput.tsx +++ b/app/javascript/components/MapEditor/components/ImportFileInput.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; +import { useState, useCallback, MouseEvent, ChangeEvent } from 'react'; import type { FeatureCollection } from 'geojson'; import invariant from 'tiny-invariant'; diff --git a/app/javascript/components/MapEditor/components/PointInput.tsx b/app/javascript/components/MapEditor/components/PointInput.tsx index 4f2fb4ead..5cd2e342f 100644 --- a/app/javascript/components/MapEditor/components/PointInput.tsx +++ b/app/javascript/components/MapEditor/components/PointInput.tsx @@ -1,4 +1,4 @@ -import React, { useState, useId } from 'react'; +import { useState, useId } from 'react'; import { fire } from '@utils'; import type { Feature, FeatureCollection } from 'geojson'; import CoordinateInput from 'react-coordinate-input'; diff --git a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx index add1dca03..807730cb3 100644 --- a/app/javascript/components/MapReader/components/GeoJSONLayer.tsx +++ b/app/javascript/components/MapReader/components/GeoJSONLayer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { Popup, LngLatBoundsLike, LngLatLike } from 'maplibre-gl'; import type { Feature, FeatureCollection, Point } from 'geojson'; diff --git a/app/javascript/components/MapReader/index.tsx b/app/javascript/components/MapReader/index.tsx index 1f9fb07d4..a8662f152 100644 --- a/app/javascript/components/MapReader/index.tsx +++ b/app/javascript/components/MapReader/index.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { FeatureCollection } from 'geojson'; diff --git a/app/javascript/components/shared/FlashMessage.tsx b/app/javascript/components/shared/FlashMessage.tsx index 4b358df3c..964a58d56 100644 --- a/app/javascript/components/shared/FlashMessage.tsx +++ b/app/javascript/components/shared/FlashMessage.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { createPortal } from 'react-dom'; import invariant from 'tiny-invariant'; diff --git a/app/javascript/components/shared/maplibre/MapLibre.tsx b/app/javascript/components/shared/maplibre/MapLibre.tsx index b2045b6d0..d160775f8 100644 --- a/app/javascript/components/shared/maplibre/MapLibre.tsx +++ b/app/javascript/components/shared/maplibre/MapLibre.tsx @@ -1,4 +1,4 @@ -import React, { +import { useState, useContext, useRef, diff --git a/app/javascript/components/shared/maplibre/StyleControl.tsx b/app/javascript/components/shared/maplibre/StyleControl.tsx index ce83b75c1..afd46345c 100644 --- a/app/javascript/components/shared/maplibre/StyleControl.tsx +++ b/app/javascript/components/shared/maplibre/StyleControl.tsx @@ -1,4 +1,4 @@ -import React, { useState, useId } from 'react'; +import { useState, useId } from 'react'; import { Popover, RadioGroup } from '@headlessui/react'; import { usePopper } from 'react-popper'; import { MapIcon } from '@heroicons/react/outline'; diff --git a/app/javascript/controllers/react_controller.tsx b/app/javascript/controllers/react_controller.tsx deleted file mode 100644 index cdaff89de..000000000 --- a/app/javascript/controllers/react_controller.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Controller } from '@hotwired/stimulus'; -import React, { lazy, Suspense, FunctionComponent } 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>(); -const components = import.meta.glob('../components/*.tsx'); - -for (const [path, loader] of Object.entries(components)) { - const [filename] = path.split('/').reverse(); - const componentClassName = filename.replace(/\.(ts|tsx)$/, ''); - console.debug( - `Registered lazy default export for "${componentClassName}" component` - ); - componentsRegistry.set( - componentClassName, - LoadableComponent(loader as 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/controllers/turbo_controller.ts b/app/javascript/controllers/turbo_controller.ts index 6c8374bc5..d210b3933 100644 --- a/app/javascript/controllers/turbo_controller.ts +++ b/app/javascript/controllers/turbo_controller.ts @@ -1,7 +1,9 @@ import { Actions } from '@coldwired/actions'; import { parseTurboStream } from '@coldwired/turbo-stream'; +import { createRoot, createReactPlugin, type Root } from '@coldwired/react'; import invariant from 'tiny-invariant'; import { session as TurboSession, type StreamElement } from '@hotwired/turbo'; +import type { ComponentType } from 'react'; import { ApplicationController } from './application_controller'; @@ -20,6 +22,7 @@ export class TurboController extends ApplicationController { #submitting = false; #actions?: Actions; + #root?: Root; // `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 @@ -32,6 +35,17 @@ export class TurboController extends ApplicationController { } connect() { + this.#root = createRoot({ + layoutComponentName: 'Layout/Layout', + loader, + schema: { + fragmentTagName: 'react-fragment', + componentTagName: 'react-component', + slotTagName: 'react-slot', + loadingClassName: 'loading' + } + }); + const plugin = createReactPlugin(this.#root); this.#actions = new Actions({ element: document.body, schema: { @@ -40,6 +54,7 @@ export class TurboController extends ApplicationController { focusDirectionAttribute: 'data-turbo-focus-direction', hiddenClassName: 'hidden' }, + plugins: [plugin], debug: false }); @@ -73,6 +88,11 @@ export class TurboController extends ApplicationController { }); } + disconnect(): void { + this.#actions?.disconnect(); + this.#root?.destroy(); + } + private startSpinner() { this.#submitting = true; this.actions.show({ targets: this.spinnerTargets }); @@ -89,3 +109,24 @@ export class TurboController extends ApplicationController { } } } + +type Loader = (exportName: string) => Promise>; +const componentsRegistry: Record = {}; +const components = import.meta.glob('../components/*.tsx'); + +const loader: Loader = (name) => { + const [moduleName, exportName] = name.split('/'); + const loader = componentsRegistry[moduleName]; + invariant(loader, `Cannot find a React component with name "${name}"`); + return loader(exportName ?? 'default'); +}; + +for (const [path, loader] of Object.entries(components)) { + const [filename] = path.split('/').reverse(); + const componentClassName = filename.replace(/\.(ts|tsx)$/, ''); + console.debug(`Registered lazy export for "${componentClassName}" component`); + componentsRegistry[componentClassName] = (exportName) => + loader().then( + (m) => (m as Record>)[exportName] + ); +} diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index ceb84ff47..91fc4699e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -59,7 +59,5 @@ - else = render 'footer' - - if Rails.env.development? - = vite_typescript_tag 'axe-core' = yield :charts_js = render Attachment::ProgressBarComponent.new diff --git a/app/views/manager/application/_javascript.html.erb b/app/views/manager/application/_javascript.html.erb index 570441489..56f773546 100644 --- a/app/views/manager/application/_javascript.html.erb +++ b/app/views/manager/application/_javascript.html.erb @@ -11,6 +11,8 @@ by providing a `content_for(:javascript)` block. <%= javascript_include_tag js_path %> <% end %> +<%= vite_client_tag %> +<%= vite_react_refresh_tag %> <%= vite_typescript_tag 'manager' %> <%= yield :javascript %> diff --git a/bun.lockb b/bun.lockb index 7c777a111..608dba50f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index edad7ebc6..9f773fc21 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "type": "module", "dependencies": { - "@coldwired/actions": "^0.11.2", - "@coldwired/turbo-stream": "^0.11.1", - "@coldwired/utils": "^0.11.4", + "@coldwired/actions": "^0.13.0", + "@coldwired/react": "^0.15.0", + "@coldwired/turbo-stream": "^0.13.0", + "@coldwired/utils": "^0.13.0", "@frsource/autoresize-textarea": "^2.0.75", "@gouvfr/dsfr": "^1.11.2", "@graphiql/plugin-explorer": "^3.0.2", @@ -17,7 +18,6 @@ "@rails/actiontext": "^7.1.3-2", "@rails/activestorage": "^7.1.3-2", "@rails/ujs": "^7.1.3-2", - "@reach/combobox": "^0.17.0", "@reach/slider": "^0.17.0", "@sentry/browser": "8.7.0", "@tiptap/core": "^2.2.4", @@ -50,31 +50,32 @@ "graphiql": "^3.2.3", "graphql": "^16.8.1", "highcharts": "^10.3.3", - "is-hotkey": "^0.2.0", "lightgallery": "^2.7.2", "maplibre-gl": "^1.15.2", "match-sorter": "^6.3.4", "patch-package": "^8.0.0", - "react": "^18.2.0", + "react": "^18.3.0", + "react-aria-components": "^1.2.0", "react-coordinate-input": "^1.0.0", - "react-dom": "^18.2.0", + "react-dom": "^18.3.0", "react-popper": "^2.3.0", - "react-query": "^3.39.3", + "react-use-event-hook": "^0.9.6", "spectaql": "^2.3.1", "stimulus-use": "^0.52.2", "terser": "^5.31.0", "tiny-invariant": "^1.3.3", "tippy.js": "^6.3.7", "trix": "^1.2.3", - "use-debounce": "^9.0.4", + "usehooks-ts": "^3.1.0", "zod": "^3.20.2" }, "devDependencies": { "@esbuild/darwin-arm64": "=0.19.9", "@esbuild/linux-x64": "=0.19.9", "@esbuild/win32-x64": "=0.19.9", - "@rollup/rollup-linux-x64-gnu": "=4.9.1", + "@react-aria/optimize-locales-plugin": "^1.1.0", "@rollup/rollup-darwin-arm64": "=4.9.1", + "@rollup/rollup-linux-x64-gnu": "=4.9.1", "@rollup/rollup-win32-x64-msvc": "=4.9.1", "@types/debounce": "^1.2.4", "@types/geojson": "^7946.0.14", @@ -82,8 +83,8 @@ "@types/mapbox__mapbox-gl-draw": "^1.2.5", "@types/rails__activestorage": "^7.1.1", "@types/rails__ujs": "^6.0.4", - "@types/react": "^17.0.43", - "@types/react-dom": "^17.0.14", + "@types/react": "^18.2.79", + "@types/react-dom": "^18.2.25", "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^7.11.0", "@typescript-eslint/parser": "^7.11.0", @@ -169,6 +170,7 @@ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended", + "plugin:react/jsx-runtime", "prettier" ], "rules": { diff --git a/tsconfig.json b/tsconfig.json index dc38473b9..4240d0dc7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "target": "ES2019", "moduleResolution": "node", "module": "es2020", - "jsx": "react", + "jsx": "react-jsx", "esModuleInterop": true, "experimentalDecorators": true, "isolatedModules": true, @@ -13,7 +13,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "types": ["react/next", "react-dom/next", "vite/client"], + "types": ["vite/client"], "paths": { "~/*": ["./app/javascript/*"], "@utils": ["./app/javascript/shared/utils.ts"] diff --git a/vite.config.ts b/vite.config.ts index 036c8e0c8..f0f0dfde8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,14 +2,21 @@ import { defineConfig } from 'vite'; import ViteReact from '@vitejs/plugin-react'; import RubyPlugin from 'vite-plugin-ruby'; import FullReload from 'vite-plugin-full-reload'; +import optimizeLocales from '@react-aria/optimize-locales-plugin'; const plugins = [ RubyPlugin(), - ViteReact({ jsxRuntime: 'classic' }), + ViteReact(), FullReload( ['config/routes.rb', 'app/views/**/*', 'app/components/**/*.haml'], { delay: 200 } - ) + ), + { + ...optimizeLocales.vite({ + locales: ['en-GB', 'fr-FR'] + }), + enforce: 'pre' as const + } ]; export default defineConfig({