From 6b5a0dfacee1e42cb5158808cdc0f969757b4c89 Mon Sep 17 00:00:00 2001 From: Titouan Galopin Date: Thu, 2 Jun 2022 12:43:03 +0200 Subject: [PATCH] Introduce UX React component --- .github/workflows/test.yaml | 17 ++++ babel.config.js | 1 + jest.config.js | 2 +- package.json | 3 +- .../assets/dist/live_controller.js | 13 +-- src/React/.gitattributes | 5 ++ src/React/.gitignore | 4 + src/React/.symfony.bundle.yaml | 3 + src/React/CHANGELOG.md | 5 ++ .../DependencyInjection/ReactExtension.php | 36 ++++++++ src/React/LICENSE | 19 ++++ src/React/README.md | 14 +++ src/React/ReactBundle.php | 24 +++++ .../assets/dist/register_controller.js | 16 ++++ .../assets/dist/render_controller.js | 43 +++++++++ src/React/Resources/assets/jest.config.js | 1 + src/React/Resources/assets/package.json | 30 +++++++ .../assets/src/register_controller.ts | 30 +++++++ .../Resources/assets/src/render_controller.ts | 53 +++++++++++ .../assets/test/fixtures/MyJsxComponent.jsx | 3 + .../assets/test/fixtures/MyTsxComponent.tsx | 5 ++ .../assets/test/register_controller.test.tsx | 27 ++++++ .../assets/test/render_controller.test.tsx | 71 +++++++++++++++ .../test/util/require_context_poylfill.ts | 39 ++++++++ src/React/Resources/doc/index.rst | 90 +++++++++++++++++++ src/React/Tests/Kernel/AppKernelTrait.php | 41 +++++++++ src/React/Tests/Kernel/FrameworkAppKernel.php | 42 +++++++++ src/React/Tests/Kernel/TwigAppKernel.php | 47 ++++++++++ src/React/Tests/ReactBundleTest.php | 40 +++++++++ .../Twig/ReactComponentExtensionTest.php | 44 +++++++++ src/React/Twig/ReactComponentExtension.php | 48 ++++++++++ src/React/composer.json | 44 +++++++++ src/React/phpunit.xml.dist | 35 ++++++++ tsconfig.json | 3 +- 34 files changed, 890 insertions(+), 8 deletions(-) create mode 100644 src/React/.gitattributes create mode 100644 src/React/.gitignore create mode 100644 src/React/.symfony.bundle.yaml create mode 100644 src/React/CHANGELOG.md create mode 100644 src/React/DependencyInjection/ReactExtension.php create mode 100644 src/React/LICENSE create mode 100644 src/React/README.md create mode 100644 src/React/ReactBundle.php create mode 100644 src/React/Resources/assets/dist/register_controller.js create mode 100644 src/React/Resources/assets/dist/render_controller.js create mode 100644 src/React/Resources/assets/jest.config.js create mode 100644 src/React/Resources/assets/package.json create mode 100644 src/React/Resources/assets/src/register_controller.ts create mode 100644 src/React/Resources/assets/src/render_controller.ts create mode 100644 src/React/Resources/assets/test/fixtures/MyJsxComponent.jsx create mode 100644 src/React/Resources/assets/test/fixtures/MyTsxComponent.tsx create mode 100644 src/React/Resources/assets/test/register_controller.test.tsx create mode 100644 src/React/Resources/assets/test/render_controller.test.tsx create mode 100644 src/React/Resources/assets/test/util/require_context_poylfill.ts create mode 100644 src/React/Resources/doc/index.rst create mode 100644 src/React/Tests/Kernel/AppKernelTrait.php create mode 100644 src/React/Tests/Kernel/FrameworkAppKernel.php create mode 100644 src/React/Tests/Kernel/TwigAppKernel.php create mode 100644 src/React/Tests/ReactBundleTest.php create mode 100644 src/React/Tests/Twig/ReactComponentExtensionTest.php create mode 100644 src/React/Twig/ReactComponentExtension.php create mode 100644 src/React/composer.json create mode 100644 src/React/phpunit.xml.dist diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 3e9bf45da78..24bcf89fe5a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -101,6 +101,15 @@ jobs: run: php vendor/bin/simple-phpunit working-directory: src/LazyImage + - name: React Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/React + dependency-versions: lowest + - name: React Tests + run: php vendor/bin/simple-phpunit + working-directory: src/React + tests-php8-low-deps: runs-on: ubuntu-latest steps: @@ -184,6 +193,14 @@ jobs: working-directory: src/LiveComponent run: php vendor/bin/simple-phpunit + - name: React Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/React + - name: React Tests + working-directory: src/React + run: php vendor/bin/simple-phpunit + tests-php81-high-deps: runs-on: ubuntu-latest steps: diff --git a/babel.config.js b/babel.config.js index e7118c30a9c..968371cd973 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,6 +1,7 @@ module.exports = { presets: [ ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/react', '@babel/preset-typescript', ], }; diff --git a/jest.config.js b/jest.config.js index 9bc582d19f8..a24ae93d8e2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,6 @@ module.exports = { path.join(__dirname, 'test/setup.js'), ], transform: { - '\\.(j|t)s$': ['babel-jest', { configFile: path.join(__dirname, './babel.config.js') }] + '\\.(j|t)s': ['babel-jest', { configFile: path.join(__dirname, './babel.config.js') }] }, } diff --git a/package.json b/package.json index 4f79006e47b..ed9d85209a9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "devDependencies": { "@babel/core": "^7.15.8", "@babel/preset-env": "^7.15.8", - "@babel/preset-typescript": "^7.15.0", + "@babel/preset-react": "^7.15.8", + "@babel/preset-typescript": "^7.15.8", "@rollup/plugin-node-resolve": "^13.0.0", "@rollup/plugin-typescript": "^8.3.0", "@symfony/stimulus-testing": "^2.0.1", diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index e1c5fd959b7..6bc2d23fb15 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -971,11 +971,14 @@ function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJso } function normalizeAttributesForComparison(element) { - if (element.value) { - element.setAttribute('value', element.value); - } - else if (element.hasAttribute('value')) { - element.setAttribute('value', ''); + const isFileInput = element instanceof HTMLInputElement && element.type === 'file'; + if (!isFileInput) { + if (element.value) { + element.setAttribute('value', element.value); + } + else if (element.hasAttribute('value')) { + element.setAttribute('value', ''); + } } Array.from(element.children).forEach((child) => { normalizeAttributesForComparison(child); diff --git a/src/React/.gitattributes b/src/React/.gitattributes new file mode 100644 index 00000000000..6ce5e72eb6f --- /dev/null +++ b/src/React/.gitattributes @@ -0,0 +1,5 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/Resources/assets/test export-ignore +/Tests export-ignore diff --git a/src/React/.gitignore b/src/React/.gitignore new file mode 100644 index 00000000000..30282084317 --- /dev/null +++ b/src/React/.gitignore @@ -0,0 +1,4 @@ +vendor +composer.lock +.php_cs.cache +.phpunit.result.cache diff --git a/src/React/.symfony.bundle.yaml b/src/React/.symfony.bundle.yaml new file mode 100644 index 00000000000..50b8d4a3040 --- /dev/null +++ b/src/React/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "Resources/doc" diff --git a/src/React/CHANGELOG.md b/src/React/CHANGELOG.md new file mode 100644 index 00000000000..a4085c2dd3b --- /dev/null +++ b/src/React/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.2 + +- Component added diff --git a/src/React/DependencyInjection/ReactExtension.php b/src/React/DependencyInjection/ReactExtension.php new file mode 100644 index 00000000000..99ee33f53a8 --- /dev/null +++ b/src/React/DependencyInjection/ReactExtension.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\DependencyInjection; + +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\UX\React\Twig\ReactComponentExtension; + +/** + * @author Titouan Galopin + * + * @internal + */ +class ReactExtension extends Extension +{ + public function load(array $configs, ContainerBuilder $container) + { + $container + ->setDefinition('twig.extension.react', new Definition(ReactComponentExtension::class)) + ->setArgument(0, new Reference('webpack_encore.twig_stimulus_extension')) + ->addTag('twig.extension') + ->setPublic(false) + ; + } +} diff --git a/src/React/LICENSE b/src/React/LICENSE new file mode 100644 index 00000000000..ad85e173748 --- /dev/null +++ b/src/React/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020-2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/React/README.md b/src/React/README.md new file mode 100644 index 00000000000..8e7fb7ba204 --- /dev/null +++ b/src/React/README.md @@ -0,0 +1,14 @@ +# Symfony UX React + +Symfony UX React integrates [React](https://reactjs.org/) into Symfony applications. +It provides tools to render React components from Twig. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-react/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/React/ReactBundle.php b/src/React/ReactBundle.php new file mode 100644 index 00000000000..0ed553e3bb3 --- /dev/null +++ b/src/React/ReactBundle.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Titouan Galopin + * + * @final + * @experimental + */ +class ReactBundle extends Bundle +{ +} diff --git a/src/React/Resources/assets/dist/register_controller.js b/src/React/Resources/assets/dist/register_controller.js new file mode 100644 index 00000000000..564eb6128e1 --- /dev/null +++ b/src/React/Resources/assets/dist/register_controller.js @@ -0,0 +1,16 @@ +function registerReactControllerComponents(context) { + const reactControllers = {}; + const importAllReactComponents = (r) => { + r.keys().forEach((key) => (reactControllers[key] = r(key).default)); + }; + importAllReactComponents(context); + window.resolveReactComponent = (name) => { + const component = reactControllers['./' + name + '.jsx'] || reactControllers['./' + name + '.tsx']; + if (typeof component === 'undefined') { + throw new Error('React controller "' + name + '" does not exist'); + } + return component; + }; +} + +export { registerReactControllerComponents }; diff --git a/src/React/Resources/assets/dist/render_controller.js b/src/React/Resources/assets/dist/render_controller.js new file mode 100644 index 00000000000..d6e5ba59e60 --- /dev/null +++ b/src/React/Resources/assets/dist/render_controller.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { Controller } from '@hotwired/stimulus'; + +class default_1 extends Controller { + connect() { + this._dispatchEvent('react:connect', { component: this.componentValue, props: this.propsValue }); + const component = window.resolveReactComponent(this.componentValue); + this._renderReactElement(React.createElement(component, this.propsValue, null)); + this._dispatchEvent('react:mount', { + componentName: this.componentValue, + component: component, + props: this.propsValue, + }); + } + disconnect() { + this.element.unmount(); + this._dispatchEvent('react:unmount', { component: this.componentValue, props: this.propsValue }); + } + _renderReactElement(reactElement) { + if (parseInt(React.version) >= 18) { + const root = require('react-dom/client').createRoot(this.element); + root.render(reactElement); + this.element.unmount = () => { + root.unmount(); + }; + return; + } + const reactDom = require('react-dom'); + reactDom.render(reactElement, this.element); + this.element.unmount = () => { + reactDom.unmountComponentAtNode(this.element); + }; + } + _dispatchEvent(name, payload) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); + } +} +default_1.values = { + component: String, + props: Object, +}; + +export { default_1 as default }; diff --git a/src/React/Resources/assets/jest.config.js b/src/React/Resources/assets/jest.config.js new file mode 100644 index 00000000000..6358ddf2772 --- /dev/null +++ b/src/React/Resources/assets/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../../../jest.config.js'); diff --git a/src/React/Resources/assets/package.json b/src/React/Resources/assets/package.json new file mode 100644 index 00000000000..4b0b2ba510f --- /dev/null +++ b/src/React/Resources/assets/package.json @@ -0,0 +1,30 @@ +{ + "name": "@symfony/ux-react", + "description": "Integration of React in Symfony", + "license": "MIT", + "version": "1.0.0", + "main": "dist/register_controller.js", + "symfony": { + "controllers": { + "react": { + "main": "dist/render_controller.js", + "webpackMode": "eager", + "fetch": "eager", + "enabled": true + } + } + }, + "peerDependencies": { + "@hotwired/stimulus": "^3.0.0", + "react": "^18.0", + "react-dom": "^18.0" + }, + "devDependencies": { + "@hotwired/stimulus": "^3.0.0", + "@types/react": "^18.0", + "@types/react-dom": "^18.0", + "@types/webpack-env": "^1.16", + "react": "^18.0", + "react-dom": "^18.0" + } +} diff --git a/src/React/Resources/assets/src/register_controller.ts b/src/React/Resources/assets/src/register_controller.ts new file mode 100644 index 00000000000..6d278e74dc0 --- /dev/null +++ b/src/React/Resources/assets/src/register_controller.ts @@ -0,0 +1,30 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +export function registerReactControllerComponents(context: __WebpackModuleApi.RequireContext) { + const reactControllers: { [key: string]: object } = {}; + + const importAllReactComponents = (r: __WebpackModuleApi.RequireContext) => { + r.keys().forEach((key) => (reactControllers[key] = r(key).default)); + }; + + importAllReactComponents(context); + + // Expose a global React loader to allow rendering from the Stimulus controller + (window as any).resolveReactComponent = (name: string): object => { + const component = reactControllers[`./${name}.jsx`] || reactControllers[`./${name}.tsx`]; + if (typeof component === 'undefined') { + throw new Error('React controller "' + name + '" does not exist'); + } + + return component; + }; +} diff --git a/src/React/Resources/assets/src/render_controller.ts b/src/React/Resources/assets/src/render_controller.ts new file mode 100644 index 00000000000..d714d461394 --- /dev/null +++ b/src/React/Resources/assets/src/render_controller.ts @@ -0,0 +1,53 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import React, { ReactElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + readonly componentValue: string; + readonly propsValue: object; + + static values = { + component: String, + props: Object, + }; + + connect() { + this._dispatchEvent('react:connect', { component: this.componentValue, props: this.propsValue }); + + const component = window.resolveReactComponent(this.componentValue); + this._renderReactElement(React.createElement(component, this.propsValue, null)); + + this._dispatchEvent('react:mount', { + componentName: this.componentValue, + component: component, + props: this.propsValue, + }); + } + + disconnect() { + (this.element as any).root.unmount(); + this._dispatchEvent('react:unmount', { component: this.componentValue, props: this.propsValue }); + } + + _renderReactElement(reactElement: ReactElement) { + const root = createRoot(this.element); + root.render(reactElement); + + (this.element as any).root = root; + } + + _dispatchEvent(name: string, payload: any) { + this.element.dispatchEvent(new CustomEvent(name, { detail: payload, bubbles: true })); + } +} diff --git a/src/React/Resources/assets/test/fixtures/MyJsxComponent.jsx b/src/React/Resources/assets/test/fixtures/MyJsxComponent.jsx new file mode 100644 index 00000000000..c91fee085da --- /dev/null +++ b/src/React/Resources/assets/test/fixtures/MyJsxComponent.jsx @@ -0,0 +1,3 @@ +export default function () { + return
Hello
; +} diff --git a/src/React/Resources/assets/test/fixtures/MyTsxComponent.tsx b/src/React/Resources/assets/test/fixtures/MyTsxComponent.tsx new file mode 100644 index 00000000000..5cfa1dc3f95 --- /dev/null +++ b/src/React/Resources/assets/test/fixtures/MyTsxComponent.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export default function () { + return
Hello
; +} diff --git a/src/React/Resources/assets/test/register_controller.test.tsx b/src/React/Resources/assets/test/register_controller.test.tsx new file mode 100644 index 00000000000..23fa5ce49ce --- /dev/null +++ b/src/React/Resources/assets/test/register_controller.test.tsx @@ -0,0 +1,27 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {registerReactControllerComponents} from '../src/register_controller'; +import MyTsxComponent from './fixtures/MyTsxComponent'; +import {createRequireContextPolyfill} from './util/require_context_poylfill'; + +require.context = createRequireContextPolyfill(__dirname); + +describe('registerReactControllerComponents', () => { + it('test', () => { + registerReactControllerComponents(require.context('./fixtures', true, /\.(j|t)sx$/)); + const resolveComponent = (window as any).resolveReactComponent; + + expect(resolveComponent).not.toBeUndefined(); + expect(resolveComponent('MyTsxComponent')).toBe(MyTsxComponent); + expect(resolveComponent('MyJsxComponent')).not.toBeUndefined(); + }); +}); diff --git a/src/React/Resources/assets/test/render_controller.test.tsx b/src/React/Resources/assets/test/render_controller.test.tsx new file mode 100644 index 00000000000..c72c250355d --- /dev/null +++ b/src/React/Resources/assets/test/render_controller.test.tsx @@ -0,0 +1,71 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import React from 'react'; +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import ReactController from '../src/render_controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('react:connect', () => { + this.element.classList.add('connected'); + }); + + this.element.addEventListener('react:mount', () => { + this.element.classList.add('mounted'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('react', ReactController); +}; + +function ReactComponent(props: { fullName: string }) { + return
Hello {props.fullName}
; +} + +(window as any).resolveReactComponent = () => { + return ReactComponent; +}; + +describe('ReactController', () => { + let container: any; + + beforeEach(() => { + container = mountDOM(` +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect', async () => { + const component = getByTestId(container, 'component'); + expect(component).not.toHaveClass('connected'); + expect(component).not.toHaveClass('mounted'); + + startStimulus(); + await waitFor(() => expect(component).toHaveClass('connected')); + await waitFor(() => expect(component).toHaveClass('mounted')); + await waitFor(() => expect(component.innerHTML).toEqual('
Hello Titouan Galopin
')); + }); +}); diff --git a/src/React/Resources/assets/test/util/require_context_poylfill.ts b/src/React/Resources/assets/test/util/require_context_poylfill.ts new file mode 100644 index 00000000000..376746d8f08 --- /dev/null +++ b/src/React/Resources/assets/test/util/require_context_poylfill.ts @@ -0,0 +1,39 @@ +import fs from 'fs'; +import path from 'path'; + +export function createRequireContextPolyfill (rootDir: string) { + return (base: string, deep: boolean, filter: RegExp): __WebpackModuleApi.RequireContext => { + const basePrefix = path.resolve(rootDir, base); + const files: { [key: string]: boolean } = {}; + + function readDirectory(directory: string) { + fs.readdirSync(directory).forEach((file: string) => { + const fullPath = path.resolve(directory, file); + + if (fs.statSync(fullPath).isDirectory()) { + if (deep) { + readDirectory(fullPath); + } + + return; + } + + if (!filter.test(fullPath)) { + return; + } + + files[fullPath.replace(basePrefix, '.')] = true; + }); + } + + readDirectory(path.resolve(rootDir, base)); + + function Module(file: string) { + return require(basePrefix + '/' + file); + } + + Module.keys = () => Object.keys(files); + + return (Module as __WebpackModuleApi.RequireContext); + }; +} diff --git a/src/React/Resources/doc/index.rst b/src/React/Resources/doc/index.rst new file mode 100644 index 00000000000..9771a34ec00 --- /dev/null +++ b/src/React/Resources/doc/index.rst @@ -0,0 +1,90 @@ +Symfony UX React +================ + +Symfony UX React is a Symfony bundle integrating `React`_ in +Symfony applications. It is part of `the Symfony UX initiative`_. + +React is a JavaScript library for building user interfaces. +Symfony UX React provides tools to render React components from Twig, +handling rendering and data transfers. + +Symfony UX React supports React 18+. + +Installation +------------ + +Before you start, make sure you have `Symfony UX configured in your app`_. + +Then install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-react + + # Don't forget to install the JavaScript dependencies as well and compile + $ yarn install --force + $ yarn encore dev + +You also need to add the following lines at the end to your ``assets/app.js`` file: + +.. code-block:: javascript + + // assets/app.js + import { registerReactControllerComponents } from '@symfony/ux-react'; + + // Registers React controller components to allow loading them from Twig + // + // React controller components are components that are meant to be rendered + // from Twig. These component then rely on other components that won't be called + // directly from Twig. + // + // By putting only controller components in `react/controllers`, you ensure that + // internal components won't be automatically included in your JS built file if + // they are not necessary. + registerReactControllerComponents(require.context('./react/controllers', true, /\.(j|t)sx?$/)); + + +Usage +----- + +UX React works by using a system of **React controller components**: React components that +are registered using ``registerReactControllerComponents`` and that are meant to be rendered +from Twig. + +When using the ``registerReactControllerComponents`` configuration shown previously, all +React components located in the directory ``assets/react/controllers`` are registered as +React controller components. + +You can then render any React controller component in Twig using the ``react_component``. +For example: + +.. code-block:: javascript + + // assets/react/controllers/MyComponent.jsx + import React from 'react'; + + export default function (props) { + return
Hello {props.fullName}
; + } + + +.. code-block:: twig + + {# templates/home.html.twig #} + +
+ +Backward Compatibility promise +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +However it is currently considered `experimental`_, +meaning it is not bound to Symfony's BC policy for the moment. + +.. _`React`: https://reactjs.org/ +.. _`the Symfony UX initiative`: https://symfony.com/ux +.. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html +.. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html diff --git a/src/React/Tests/Kernel/AppKernelTrait.php b/src/React/Tests/Kernel/AppKernelTrait.php new file mode 100644 index 00000000000..cd254e41336 --- /dev/null +++ b/src/React/Tests/Kernel/AppKernelTrait.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\Tests\Kernel; + +/** + * @author Titouan Galopin + * + * @internal + */ +trait AppKernelTrait +{ + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/react_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } +} diff --git a/src/React/Tests/Kernel/FrameworkAppKernel.php b/src/React/Tests/Kernel/FrameworkAppKernel.php new file mode 100644 index 00000000000..26209afc4fe --- /dev/null +++ b/src/React/Tests/Kernel/FrameworkAppKernel.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\React\ReactBundle; +use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; + +/** + * @author Titouan Galopin + * + * @internal + */ +class FrameworkAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new WebpackEncoreBundle(), new FrameworkBundle(), new ReactBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); + $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); + }); + } +} diff --git a/src/React/Tests/Kernel/TwigAppKernel.php b/src/React/Tests/Kernel/TwigAppKernel.php new file mode 100644 index 00000000000..e3f1553baac --- /dev/null +++ b/src/React/Tests/Kernel/TwigAppKernel.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\React\ReactBundle; +use Symfony\WebpackEncoreBundle\WebpackEncoreBundle; + +/** + * @author Titouan Galopin + * + * @internal + */ +class TwigAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new WebpackEncoreBundle(), new FrameworkBundle(), new TwigBundle(), new ReactBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true]); + $container->loadFromExtension('webpack_encore', ['output_path' => '%kernel.project_dir%/public/build']); + $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); + + $container->setAlias('test.twig', 'twig')->setPublic(true); + $container->setAlias('test.twig.extension.react', 'twig.extension.react')->setPublic(true); + }); + } +} diff --git a/src/React/Tests/ReactBundleTest.php b/src/React/Tests/ReactBundleTest.php new file mode 100644 index 00000000000..bc0c982f033 --- /dev/null +++ b/src/React/Tests/ReactBundleTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\React\Tests\Kernel\FrameworkAppKernel; +use Symfony\UX\React\Tests\Kernel\TwigAppKernel; + +/** + * @author Titouan Galopin + * + * @internal + */ +class ReactBundleTest extends TestCase +{ + public function provideKernels() + { + yield 'framework' => [new FrameworkAppKernel('test', true)]; + yield 'twig' => [new TwigAppKernel('test', true)]; + } + + /** + * @dataProvider provideKernels + */ + public function testBootKernel(Kernel $kernel) + { + $kernel->boot(); + $this->assertArrayHasKey('ReactBundle', $kernel->getBundles()); + } +} diff --git a/src/React/Tests/Twig/ReactComponentExtensionTest.php b/src/React/Tests/Twig/ReactComponentExtensionTest.php new file mode 100644 index 00000000000..f0603f9ecf4 --- /dev/null +++ b/src/React/Tests/Twig/ReactComponentExtensionTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\React\Tests\Kernel\TwigAppKernel; +use Symfony\UX\React\Twig\ReactComponentExtension; + +/** + * @author Titouan Galopin + * + * @internal + */ +class ReactComponentExtensionTest extends TestCase +{ + public function testRenderComponent() + { + $kernel = new TwigAppKernel('test', true); + $kernel->boot(); + + /** @var ReactComponentExtension $extension */ + $extension = $kernel->getContainer()->get('test.twig.extension.react'); + + $rendered = $extension->renderReactComponent( + $kernel->getContainer()->get('test.twig'), + 'SubDir/MyComponent', + ['fullName' => 'Titouan Galopin'] + ); + + $this->assertSame( + 'data-controller="symfony--ux-react--react" data-symfony--ux-react--react-component-value="SubDir/MyComponent" data-symfony--ux-react--react-props-value="{"fullName":"Titouan Galopin"}"', + $rendered + ); + } +} diff --git a/src/React/Twig/ReactComponentExtension.php b/src/React/Twig/ReactComponentExtension.php new file mode 100644 index 00000000000..5d6f5b65a64 --- /dev/null +++ b/src/React/Twig/ReactComponentExtension.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\React\Twig; + +use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; +use Twig\Environment; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +/** + * @author Titouan Galopin + * + * @final + * @experimental + */ +class ReactComponentExtension extends AbstractExtension +{ + private $stimulusExtension; + + public function __construct(StimulusTwigExtension $stimulusExtension) + { + $this->stimulusExtension = $stimulusExtension; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('react_component', [$this, 'renderReactComponent'], ['needs_environment' => true, 'is_safe' => ['html_attr']]), + ]; + } + + public function renderReactComponent(Environment $env, string $componentName, array $props = []): string + { + return $this->stimulusExtension->renderStimulusController($env, '@symfony/ux-react/react', [ + 'component' => $componentName, + 'props' => $props, + ]); + } +} diff --git a/src/React/composer.json b/src/React/composer.json new file mode 100644 index 00000000000..9ff53abf410 --- /dev/null +++ b/src/React/composer.json @@ -0,0 +1,44 @@ +{ + "name": "symfony/ux-react", + "type": "symfony-bundle", + "description": "Integration of React in Symfony", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Titouan Galopin", + "email": "galopintitouan@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\React\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "require": { + "symfony/webpack-encore-bundle": "^1.11" + }, + "require-dev": { + "symfony/framework-bundle": "^4.4|^5.0|^6.0", + "symfony/phpunit-bridge": "^5.2|^6.0", + "symfony/twig-bundle": "^4.4|^5.0|^6.0", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/React/phpunit.xml.dist b/src/React/phpunit.xml.dist new file mode 100644 index 00000000000..17c07af5582 --- /dev/null +++ b/src/React/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + Tests + + + + + + . + + ./Tests + ./Resources + ./vendor + + + + diff --git a/tsconfig.json b/tsconfig.json index 879f16113e9..07b799e4617 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,8 @@ "noEmit": false, "declaration": false, "esModuleInterop": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "jsx": "react" }, "exclude": ["src/**/dist"], "include": ["src/**/*"]