mirror of
https://github.com/symfony/ux.git
synced 2026-03-24 00:02:21 +01:00
[Native] Create the component
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"ux-google-map": "src/Map/src/Bridge/Google",
|
||||
"ux-leaflet-map": "src/Map/src/Bridge/Leaflet",
|
||||
"ux-native": "src/Native",
|
||||
"ux-notify": "src/Notify",
|
||||
"ux-react": "src/React",
|
||||
"ux-svelte": "src/Svelte",
|
||||
|
||||
5
src/Native/.gitattributes
vendored
Normal file
5
src/Native/.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/.git* export-ignore
|
||||
/.symfony.bundle.yaml export-ignore
|
||||
/doc export-ignore
|
||||
/phpunit.xml.dist export-ignore
|
||||
/tests export-ignore
|
||||
6
src/Native/.gitignore
vendored
Normal file
6
src/Native/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/var
|
||||
/vendor
|
||||
/composer.lock
|
||||
/.phpunit.cache
|
||||
/.php-cs-fixer.cache
|
||||
/phpunit.xml
|
||||
3
src/Native/.symfony.bundle.yaml
Normal file
3
src/Native/.symfony.bundle.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
branches: ['2.x']
|
||||
maintained_branches: ['2.x']
|
||||
doc_dir: 'doc'
|
||||
5
src/Native/CHANGELOG.md
Normal file
5
src/Native/CHANGELOG.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2.33
|
||||
|
||||
- Create the component
|
||||
19
src/Native/LICENCE
Normal file
19
src/Native/LICENCE
Normal file
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2026-present 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.
|
||||
25
src/Native/README.md
Normal file
25
src/Native/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Symfony UX Native
|
||||
|
||||
**EXPERIMENTAL** This component is currently experimental and is
|
||||
likely to change, or even change drastically.
|
||||
|
||||
Symfony UX Native integrates [Hotwire Native](https://native.hotwired.dev/) into Symfony applications.
|
||||
It is part of [the Symfony UX initiative](https://ux.symfony.com/).
|
||||
|
||||
**This repository is a READ-ONLY sub-tree split**. See
|
||||
https://github.com/symfony/ux to create issues or submit pull requests.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
# config/packages/ux_native.yaml
|
||||
ux_native:
|
||||
output_dir: '%kernel.project_dir%/public'
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://symfony.com/bundles/ux-native/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)
|
||||
48
src/Native/composer.json
Normal file
48
src/Native/composer.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "symfony/ux-native",
|
||||
"description": "Hotwire Native integration for Symfony",
|
||||
"license": "MIT",
|
||||
"type": "symfony-bundle",
|
||||
"keywords": ["symfony-ux", "hotwire", "native"],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"homepage": "https://symfony.com",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/asset": "^6.4|^7.0|^8.0",
|
||||
"symfony/filesystem": "^6.4|^7.0|^8.0",
|
||||
"symfony/process": "^6.4|^7.0|^8.0",
|
||||
"symfony/stimulus-bundle": "^2.18.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.6.22",
|
||||
"symfony/phpunit-bridge": "^7.2|^8.0",
|
||||
"symfony/browser-kit": "^6.4|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
|
||||
"symfony/maker-bundle": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\UX\\Native\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Symfony\\UX\\Native\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"name": "symfony/ux",
|
||||
"url": "https://github.com/symfony/ux"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "dev"
|
||||
}
|
||||
26
src/Native/config/debug.php
Normal file
26
src/Native/config/debug.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
use Symfony\UX\Native\EventListener\DevServerEventListener;
|
||||
|
||||
return static function (ContainerConfigurator $container): void {
|
||||
$services = $container->services();
|
||||
|
||||
$services->set('.ux_native.listener.dev_server_listener', DevServerEventListener::class)
|
||||
->args([
|
||||
service('.ux_native.configuration_builder'),
|
||||
])
|
||||
->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest'])
|
||||
;
|
||||
};
|
||||
42
src/Native/config/services.php
Normal file
42
src/Native/config/services.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
|
||||
use Symfony\UX\Native\EventListener\NativeListener;
|
||||
use Symfony\UX\Native\Twig\NativeExtension;
|
||||
use Symfony\UX\Native\Command\ConfigurationDumper;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
|
||||
return static function (ContainerConfigurator $container): void {
|
||||
$services = $container->services();
|
||||
|
||||
$container->services()
|
||||
->set('.ux_native.listener.native_listener', NativeListener::class)
|
||||
->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest'])
|
||||
;
|
||||
|
||||
$container->services()
|
||||
->set('ux_native.twig.native_extension', NativeExtension::class)
|
||||
->args([service('request_stack')])
|
||||
->tag('twig.extension')
|
||||
;
|
||||
|
||||
$services->set('.ux_native.configuration_builder', ConfigurationBuilder::class)
|
||||
->args([
|
||||
'%ux_native.output_dir%',
|
||||
])
|
||||
;
|
||||
$services->set('.ux_native.command.configuration_dumper', ConfigurationDumper::class)
|
||||
->args([service('.ux_native.configuration_builder')])
|
||||
->tag('console.command', ['command' => 'ux-native:dump'])
|
||||
;
|
||||
};
|
||||
360
src/Native/doc/index.rst
Normal file
360
src/Native/doc/index.rst
Normal file
@@ -0,0 +1,360 @@
|
||||
Symfony UX Native
|
||||
=================
|
||||
|
||||
**EXPERIMENTAL** This component is currently experimental and is likely
|
||||
to change, or even change drastically.
|
||||
|
||||
Symfony UX Native is a Symfony bundle integrating `Hotwire Native`_ into Symfony
|
||||
applications. It is part of `the Symfony UX initiative`_.
|
||||
|
||||
`Hotwire Native`_ is a framework for building native mobile applications (iOS and Android)
|
||||
that wrap your web application in a native shell. This bundle provides tools to:
|
||||
|
||||
- **Detect native requests** automatically, based on the ``User-Agent`` header;
|
||||
- **Conditionally render content** in Twig templates depending on whether the
|
||||
request comes from a native app or a browser;
|
||||
- **Generate JSON configuration files** consumed by Hotwire Native mobile clients
|
||||
(path rules, settings, etc.);
|
||||
- **Scaffold Bridge controllers** to enable communication between your web app
|
||||
and the native shell via Stimulus.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
.. caution::
|
||||
|
||||
Before you start, make sure you have `StimulusBundle configured in your app`_.
|
||||
|
||||
Install the bundle using Composer and Symfony Flex:
|
||||
|
||||
.. code-block:: terminal
|
||||
|
||||
$ composer require symfony/ux-native
|
||||
|
||||
If you're using WebpackEncore, install your assets and restart Encore (not
|
||||
needed if you're using AssetMapper):
|
||||
|
||||
.. code-block:: terminal
|
||||
|
||||
$ npm install --force
|
||||
$ npm run watch
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
The bundle exposes a single configuration option:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
# config/packages/ux_native.yaml
|
||||
ux_native:
|
||||
output_dir: '%kernel.project_dir%/public' # default
|
||||
|
||||
The ``output_dir`` option defines the directory where JSON configuration files
|
||||
are written when you run the ``ux-native:dump`` command (see
|
||||
`Dumping Configurations for Production`_).
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
Detecting Native Requests
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The bundle automatically listens to every incoming request and inspects the
|
||||
``User-Agent`` header. If it contains the string ``Hotwire Native`` (which is
|
||||
sent by all Hotwire Native iOS and Android clients), the request is flagged as
|
||||
a native request.
|
||||
|
||||
You do not need any additional configuration for this to work -- it is enabled
|
||||
by default.
|
||||
|
||||
In Twig templates, use the ``ux_is_native()`` function to conditionally render
|
||||
content:
|
||||
|
||||
.. code-block:: html+twig
|
||||
|
||||
{% if ux_is_native() %}
|
||||
{# Content only visible in the native app #}
|
||||
<p>Welcome to our mobile app!</p>
|
||||
{% else %}
|
||||
{# Content only visible in the browser #}
|
||||
<nav>
|
||||
<a href="/">Home</a>
|
||||
<a href="/about">About</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
This is useful for hiding navigation bars, footers, or any elements that the
|
||||
native shell already provides.
|
||||
|
||||
In PHP, you can also check the request attribute directly::
|
||||
|
||||
use Symfony\UX\Native\EventListener\NativeListener;
|
||||
|
||||
// In a controller
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$isNative = $request->attributes->get(NativeListener::NATIVE_ATTRIBUTE, false);
|
||||
|
||||
// ...
|
||||
}
|
||||
|
||||
Creating Configurations
|
||||
~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Hotwire Native mobile clients can consume JSON configuration files to control
|
||||
their behavior (e.g. path rules, pull-to-refresh, local database usage, etc.).
|
||||
This bundle lets you define those configurations as PHP objects and register
|
||||
them as Symfony services.
|
||||
|
||||
A configuration is built using two classes:
|
||||
``Symfony\UX\Native\Configuration\Configuration`` and
|
||||
``Symfony\UX\Native\Configuration\Rule``.
|
||||
|
||||
Using PHP Attributes
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The easiest way to register configurations is to use the
|
||||
``#[AsNativeConfigurationProvider]`` and ``#[AsNativeConfiguration]`` attributes.
|
||||
|
||||
Create a class annotated with ``#[AsNativeConfigurationProvider]``, and add
|
||||
``#[AsNativeConfiguration('/path/to/config.json')]`` on each method that returns
|
||||
a ``Configuration`` object::
|
||||
|
||||
// src/Native/AppNativeConfiguration.php
|
||||
namespace App\Native;
|
||||
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
|
||||
#[AsNativeConfigurationProvider]
|
||||
final class AppNativeConfiguration
|
||||
{
|
||||
#[AsNativeConfiguration('/config/ios_v1.json')]
|
||||
public function iosV1(): Configuration
|
||||
{
|
||||
return new Configuration(
|
||||
settings: [
|
||||
'use_local_db' => true,
|
||||
'cable' => [
|
||||
'script_url' => 'https://example.com/cable.js',
|
||||
],
|
||||
],
|
||||
rules: [
|
||||
new Rule(
|
||||
patterns: ['.*'],
|
||||
properties: [
|
||||
'context' => 'default',
|
||||
'pull_to_refresh_enabled' => true,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[AsNativeConfiguration('/config/android_v1.json')]
|
||||
public function androidV1(): Configuration
|
||||
{
|
||||
return new Configuration(
|
||||
settings: [
|
||||
'use_local_db' => false,
|
||||
],
|
||||
rules: [
|
||||
new Rule(
|
||||
patterns: ['/articles/.*'],
|
||||
properties: [
|
||||
'context' => 'default',
|
||||
'pull_to_refresh_enabled' => false,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
That's it! The class is automatically discovered and each annotated method is
|
||||
registered as a configuration at the given path.
|
||||
|
||||
.. note::
|
||||
|
||||
The annotated methods must be **public** and **non-static**, and must
|
||||
declare a return type of ``Configuration``.
|
||||
|
||||
Using Service Tags
|
||||
^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Alternatively, you can register configurations manually using service tags. This
|
||||
is useful if you need to use a factory pattern or if you prefer YAML
|
||||
configuration::
|
||||
|
||||
// src/Native/IosConfigurationFactory.php
|
||||
namespace App\Native;
|
||||
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
|
||||
final class IosConfigurationFactory
|
||||
{
|
||||
public static function v1(): Configuration
|
||||
{
|
||||
return new Configuration(
|
||||
settings: [
|
||||
'use_local_db' => true,
|
||||
'cable' => [
|
||||
'script_url' => 'https://example.com/cable.js',
|
||||
],
|
||||
],
|
||||
rules: [
|
||||
new Rule(
|
||||
patterns: ['.*'],
|
||||
properties: [
|
||||
'context' => 'default',
|
||||
'pull_to_refresh_enabled' => true,
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Then register it as a service tagged with ``ux_native.configuration``, and
|
||||
specify the ``path`` at which it should be served:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
# config/services.yaml
|
||||
services:
|
||||
App\Native\IosConfigurationFactory: ~
|
||||
|
||||
app.native.ios_v1:
|
||||
class: Symfony\UX\Native\Configuration\Configuration
|
||||
factory: ['App\Native\IosConfigurationFactory', 'v1']
|
||||
tags:
|
||||
- { name: 'ux_native.configuration', path: '/config/ios_v1.json' }
|
||||
|
||||
You can register as many configurations as you need (e.g. one per platform, one
|
||||
per version, etc.), each with a different ``path``.
|
||||
|
||||
The resulting JSON file will look like this:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"settings": {
|
||||
"use_local_db": true,
|
||||
"cable": {
|
||||
"script_url": "https://example.com/cable.js"
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"patterns": [
|
||||
".*"
|
||||
],
|
||||
"properties": {
|
||||
"context": "default",
|
||||
"pull_to_refresh_enabled": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Serving Configurations in Development
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
In **debug mode** (i.e. the ``dev`` environment), the bundle automatically
|
||||
serves configuration JSON responses dynamically. When a request matches a
|
||||
registered configuration path (e.g. ``/config/ios_v1.json``), the response is
|
||||
generated on the fly from the ``Configuration`` object -- no need to dump files
|
||||
to disk.
|
||||
|
||||
This means you can iterate on your configurations without running any command.
|
||||
Simply change your factory code and refresh.
|
||||
|
||||
Dumping Configurations for Production
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
In **production**, you should dump the configuration files to disk so they can
|
||||
be served as static files by your web server, without going through the Symfony
|
||||
kernel.
|
||||
|
||||
Run the following command:
|
||||
|
||||
.. code-block:: terminal
|
||||
|
||||
$ php bin/console ux-native:dump
|
||||
|
||||
This writes all registered configurations as JSON files into the ``output_dir``
|
||||
directory (defaults to ``public/``). For example, a configuration registered at
|
||||
path ``/config/ios_v1.json`` will be written to ``public/config/ios_v1.json``.
|
||||
|
||||
.. tip::
|
||||
|
||||
Add this command to your deployment pipeline (e.g. in your ``composer.json``
|
||||
post-install scripts or your CI/CD configuration) to ensure the configuration
|
||||
files are always up to date.
|
||||
|
||||
Creating Bridge Controllers
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
`Hotwire Native Bridge`_ allows your web application and the native shell to
|
||||
communicate via Stimulus controllers. The bundle provides a Maker command to
|
||||
quickly scaffold a Bridge controller.
|
||||
|
||||
.. note::
|
||||
|
||||
This command requires ``symfony/maker-bundle`` to be installed.
|
||||
|
||||
.. code-block:: terminal
|
||||
|
||||
$ php bin/console make:native-bridge-controller
|
||||
|
||||
The command will ask you for the controller name and generate a JavaScript file
|
||||
in ``assets/controllers/``. For example, creating a controller named
|
||||
``contact_form`` will generate the following file:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
// assets/controllers/contact_form_controller.js
|
||||
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
|
||||
|
||||
export default class extends BridgeComponent {
|
||||
static component = "contact_form"
|
||||
|
||||
connect() {
|
||||
super.connect()
|
||||
// The bridge is now ready and will handle communication
|
||||
// between your web app and the native application
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect()
|
||||
// Clean up any resources or event listeners here
|
||||
}
|
||||
}
|
||||
|
||||
You can then use this controller in your Twig templates:
|
||||
|
||||
.. code-block:: html+twig
|
||||
|
||||
<form data-controller="contact-form">
|
||||
{# ... #}
|
||||
</form>
|
||||
|
||||
For more information on how Bridge components work, refer to the
|
||||
`Hotwire Native Bridge documentation`_.
|
||||
|
||||
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
|
||||
|
||||
.. _`the Symfony UX initiative`: https://ux.symfony.com/
|
||||
.. _`Hotwire Native`: https://native.hotwired.dev/
|
||||
.. _`Hotwire Native Bridge`: https://native.hotwired.dev/reference/bridge-installation
|
||||
.. _`Hotwire Native Bridge documentation`: https://native.hotwired.dev/reference/bridge-installation
|
||||
.. _`StimulusBundle configured in your app`: https://symfony.com/bundles/StimulusBundle/current/index.html
|
||||
30
src/Native/phpunit.xml.dist
Normal file
30
src/Native/phpunit.xml.dist
Normal file
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/bin/.phpunit/phpunit.xsd"
|
||||
colors="true"
|
||||
bootstrap="tests/autoload.php"
|
||||
failOnRisky="true"
|
||||
failOnWarning="true"
|
||||
>
|
||||
<php>
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<env name="SHELL_VERBOSITY" value="-1"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&max[direct]=0"/>
|
||||
<env name="KERNEL_CLASS" value="Symfony\UX\Native\Tests\AppKernel"/>
|
||||
</php>
|
||||
<testsuites>
|
||||
<testsuite name="Symfony UX Native Test Suite">
|
||||
<directory>./tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<coverage>
|
||||
<include>
|
||||
<directory>./src</directory>
|
||||
</include>
|
||||
</coverage>
|
||||
<listeners>
|
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
|
||||
</listeners>
|
||||
</phpunit>
|
||||
39
src/Native/src/Attribute/AsNativeConfiguration.php
Normal file
39
src/Native/src/Attribute/AsNativeConfiguration.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Attribute;
|
||||
|
||||
/**
|
||||
* Marks a method as a Hotwire Native configuration factory.
|
||||
*
|
||||
* The method must return a {@see \Symfony\UX\Native\Configuration\Configuration} instance.
|
||||
* The containing class must be annotated with {@see AsNativeConfigurationProvider}.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* #[AsNativeConfigurationProvider]
|
||||
* class AppNativeConfiguration
|
||||
* {
|
||||
* #[AsNativeConfiguration('/config/ios_v1.json')]
|
||||
* public function iosV1(): Configuration
|
||||
* {
|
||||
* return new Configuration(...);
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_METHOD)]
|
||||
final class AsNativeConfiguration
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $path,
|
||||
) {
|
||||
}
|
||||
}
|
||||
35
src/Native/src/Attribute/AsNativeConfigurationProvider.php
Normal file
35
src/Native/src/Attribute/AsNativeConfigurationProvider.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Attribute;
|
||||
|
||||
/**
|
||||
* Marks a class as a Hotwire Native configuration provider.
|
||||
*
|
||||
* The class should contain one or more methods annotated with
|
||||
* {@see AsNativeConfiguration} that return Configuration instances.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* #[AsNativeConfigurationProvider]
|
||||
* class AppNativeConfiguration
|
||||
* {
|
||||
* #[AsNativeConfiguration('/config/ios_v1.json')]
|
||||
* public function iosV1(): Configuration
|
||||
* {
|
||||
* return new Configuration(...);
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
#[\Attribute(\Attribute::TARGET_CLASS)]
|
||||
final class AsNativeConfigurationProvider
|
||||
{
|
||||
}
|
||||
40
src/Native/src/Command/ConfigurationDumper.php
Normal file
40
src/Native/src/Command/ConfigurationDumper.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Command;
|
||||
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
|
||||
#[AsCommand('ux-native:dump')]
|
||||
final class ConfigurationDumper extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConfigurationBuilder $configurationBuilder,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->comment('Dumping UX Native configuration files...');
|
||||
|
||||
$this->configurationBuilder->build();
|
||||
$io->success('UX Native configuration files dumped successfully.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
36
src/Native/src/Configuration/Configuration.php
Normal file
36
src/Native/src/Configuration/Configuration.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Configuration;
|
||||
|
||||
class Configuration
|
||||
{
|
||||
/**
|
||||
* @param ?array<string, mixed> $settings
|
||||
* @param ?array<Rule> $rules
|
||||
*/
|
||||
public function __construct(
|
||||
public ?array $settings = null,
|
||||
public ?array $rules = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'settings' => $this->settings,
|
||||
'rules' => null === $this->rules ? null : array_map(static fn (Rule $rule) => $rule->toArray(), $this->rules),
|
||||
], static fn ($value) => null !== $value);
|
||||
}
|
||||
}
|
||||
36
src/Native/src/Configuration/Rule.php
Normal file
36
src/Native/src/Configuration/Rule.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Configuration;
|
||||
|
||||
class Rule
|
||||
{
|
||||
/**
|
||||
* @param array<string> $patterns
|
||||
* @param array<string, mixed> $properties
|
||||
*/
|
||||
public function __construct(
|
||||
public ?array $patterns = null,
|
||||
public ?array $properties = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return array_filter([
|
||||
'patterns' => $this->patterns,
|
||||
'properties' => $this->properties,
|
||||
], static fn ($value) => null !== $value);
|
||||
}
|
||||
}
|
||||
61
src/Native/src/ConfigurationBuilder.php
Normal file
61
src/Native/src/ConfigurationBuilder.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native;
|
||||
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
|
||||
final class ConfigurationBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<string, Configuration>
|
||||
*/
|
||||
private array $configs = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $outputDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public function add(string $url, Configuration $configuration): void
|
||||
{
|
||||
$this->configs[$url] = $configuration;
|
||||
}
|
||||
|
||||
public function has(string $url): bool
|
||||
{
|
||||
return \array_key_exists($url, $this->configs);
|
||||
}
|
||||
|
||||
public function get(string $pathInfo): Configuration
|
||||
{
|
||||
return $this->configs[$pathInfo] ?? throw new FileNotFoundException(\sprintf('Configuration not found for path: "%s".', $pathInfo));
|
||||
}
|
||||
|
||||
public function build(): void
|
||||
{
|
||||
$filesystem = new Filesystem();
|
||||
foreach ($this->configs as $url => $configuration) {
|
||||
$path = rtrim($this->outputDir, '/').'/'.ltrim($url, '/');
|
||||
$filesystem->dumpFile($path, $this->encode($configuration));
|
||||
}
|
||||
}
|
||||
|
||||
private function encode(Configuration $configuration): string
|
||||
{
|
||||
return json_encode(
|
||||
$configuration->toArray(),
|
||||
\JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT,
|
||||
);
|
||||
}
|
||||
}
|
||||
96
src/Native/src/ConfigurationCompilerPass.php
Normal file
96
src/Native/src/ConfigurationCompilerPass.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native;
|
||||
|
||||
use Symfony\Component\DependencyInjection\ChildDefinition;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Reference;
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
|
||||
final class ConfigurationCompilerPass implements CompilerPassInterface
|
||||
{
|
||||
private const TAG = 'ux_native.configuration';
|
||||
private const PROVIDER_TAG = 'ux_native.configuration_provider';
|
||||
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
if (!$container->hasDefinition('.ux_native.configuration_builder')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->registerAttributeConfigurations($container);
|
||||
|
||||
$definition = $container->getDefinition('.ux_native.configuration_builder');
|
||||
|
||||
foreach ($container->findTaggedServiceIds(self::TAG) as $id => $tags) {
|
||||
foreach ($tags as $attributes) {
|
||||
if (!\array_key_exists('path', $attributes)) {
|
||||
throw new \InvalidArgumentException(\sprintf('The path is missing for "%s"', $id));
|
||||
}
|
||||
$definition->addMethodCall('add', [$attributes['path'], new Reference($id)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans classes tagged with #[AsNativeConfigurationProvider] for methods
|
||||
* annotated with #[AsNativeConfiguration] and registers them as tagged
|
||||
* Configuration services.
|
||||
*/
|
||||
private function registerAttributeConfigurations(ContainerBuilder $container): void
|
||||
{
|
||||
$providerIds = $container->findTaggedServiceIds(self::PROVIDER_TAG);
|
||||
|
||||
foreach ($providerIds as $serviceId => $tags) {
|
||||
$definition = $container->getDefinition($serviceId);
|
||||
$class = $definition->getClass() ?? $serviceId;
|
||||
|
||||
try {
|
||||
$reflectionClass = new \ReflectionClass($class);
|
||||
} catch (\ReflectionException) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) {
|
||||
$attributes = $method->getAttributes(AsNativeConfiguration::class);
|
||||
|
||||
if ([] === $attributes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($method->isStatic()) {
|
||||
throw new \InvalidArgumentException(\sprintf('The method "%s::%s()" annotated with #[AsNativeConfiguration] must not be static.', $class, $method->getName()));
|
||||
}
|
||||
|
||||
$returnType = $method->getReturnType();
|
||||
if (!$returnType instanceof \ReflectionNamedType || Configuration::class !== $returnType->getName()) {
|
||||
throw new \InvalidArgumentException(\sprintf('The method "%s::%s()" annotated with #[AsNativeConfiguration] must have a return type of "%s".', $class, $method->getName(), Configuration::class));
|
||||
}
|
||||
|
||||
foreach ($attributes as $attribute) {
|
||||
$asNativeConfiguration = $attribute->newInstance();
|
||||
|
||||
$childDefinition = new ChildDefinition($serviceId);
|
||||
$childDefinition->setClass(Configuration::class);
|
||||
$childDefinition->setFactory([new Reference($serviceId), $method->getName()]);
|
||||
$childDefinition->setShared(false);
|
||||
$childDefinition->addTag(self::TAG, ['path' => $asNativeConfiguration->path]);
|
||||
|
||||
$childServiceId = \sprintf('.ux_native.configuration.%s.%s', $reflectionClass->getShortName(), $method->getName());
|
||||
$container->setDefinition($childServiceId, $childDefinition);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
src/Native/src/EventListener/DevServerEventListener.php
Normal file
41
src/Native/src/EventListener/DevServerEventListener.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\EventListener;
|
||||
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
|
||||
final class DevServerEventListener
|
||||
{
|
||||
public function __construct(
|
||||
private ConfigurationBuilder $configurationBuilder,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$pathInfo = $request->getPathInfo();
|
||||
if (!$this->configurationBuilder->has($pathInfo)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$configuration = $this->configurationBuilder->get($pathInfo);
|
||||
$event->setResponse(new JsonResponse($configuration->toArray()));
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
25
src/Native/src/EventListener/NativeListener.php
Normal file
25
src/Native/src/EventListener/NativeListener.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\EventListener;
|
||||
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
|
||||
final class NativeListener
|
||||
{
|
||||
public const NATIVE_ATTRIBUTE = '_ux_is_native';
|
||||
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
$request->attributes->set(self::NATIVE_ATTRIBUTE, str_contains($request->headers->get('User-Agent', ''), 'Hotwire Native'));
|
||||
}
|
||||
}
|
||||
111
src/Native/src/Maker/MakeNativeBridgeController.php
Normal file
111
src/Native/src/Maker/MakeNativeBridgeController.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Maker;
|
||||
|
||||
use Symfony\Bundle\MakerBundle\ConsoleStyle;
|
||||
use Symfony\Bundle\MakerBundle\DependencyBuilder;
|
||||
use Symfony\Bundle\MakerBundle\Generator;
|
||||
use Symfony\Bundle\MakerBundle\InputConfiguration;
|
||||
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
|
||||
use Symfony\Bundle\MakerBundle\Str;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Exception\RuntimeException;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
/**
|
||||
* @author Imad Zairig <imadzairig@gmail.com>
|
||||
*/
|
||||
final class MakeNativeBridgeController extends AbstractMaker
|
||||
{
|
||||
private const REQUIRED_PACKAGES = [
|
||||
'@symfony/stimulus-bundle',
|
||||
'@hotwired/hotwire-native-bridge',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private string $projectDir,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function getCommandName(): string
|
||||
{
|
||||
return 'make:native-bridge-controller';
|
||||
}
|
||||
|
||||
public static function getCommandDescription(): string
|
||||
{
|
||||
return 'Creates a new Native Bridge controller';
|
||||
}
|
||||
|
||||
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
|
||||
{
|
||||
$command
|
||||
->addArgument('name', InputArgument::OPTIONAL, 'The name of the controller (e.g. <fg=yellow>bridge</>)')
|
||||
->setHelp(<<<'EOT'
|
||||
The <info>%command.name%</info> command creates a new Hotwire Native Bridge Stimulus controller.
|
||||
|
||||
<info>php %command.full_name% ContactForm</info>
|
||||
|
||||
This will create a new <info>contact_form_controller.js</info> file in your <info>assets/controllers</info> directory
|
||||
that extends the Hotwire Native Bridge component.
|
||||
|
||||
<info>php %command.full_name%</info> (interactive)
|
||||
EOT
|
||||
);
|
||||
}
|
||||
|
||||
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
|
||||
{
|
||||
$this->ensurePackagesExist($io);
|
||||
|
||||
$controllerName = $input->getArgument('name');
|
||||
if (!$controllerName) {
|
||||
$controllerName = $io->ask('What name do you want for your Hotwire Native Bridge controller?', 'bridge', static function ($name) {
|
||||
if (!$name) {
|
||||
throw new RuntimeException('Controller name cannot be empty.');
|
||||
}
|
||||
|
||||
return $name;
|
||||
});
|
||||
}
|
||||
$controllerName = Str::asSnakeCase($controllerName);
|
||||
|
||||
$controllerPath = \sprintf('%s/assets/controllers/%s_controller.js', $this->projectDir, $controllerName);
|
||||
|
||||
if (file_exists($controllerPath) && !$io->confirm(\sprintf('The controller "%s" already exists. Overwrite it?', $controllerName), false)) {
|
||||
$io->comment('Command aborted.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$generator->generateFile(
|
||||
$controllerPath,
|
||||
__DIR__.'/../Resources/skeleton/bridge_controller.tpl.php',
|
||||
['name' => $controllerName]
|
||||
);
|
||||
|
||||
$generator->writeChanges();
|
||||
|
||||
$io->success('Hotwire Native Bridge controller created successfully!');
|
||||
$io->text([
|
||||
'Next: Add the data-controller attribute to your HTML element:',
|
||||
\sprintf('<info>data-controller="%s"</info>', str_replace('_', '-', $controllerName)),
|
||||
'',
|
||||
'Read the documentation: https://native.hotwired.dev/reference/bridge-installation',
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureDependencies(DependencyBuilder $dependencies): void
|
||||
{
|
||||
}
|
||||
}
|
||||
30
src/Native/src/Resources/skeleton/bridge_controller.tpl.php
Normal file
30
src/Native/src/Resources/skeleton/bridge_controller.tpl.php
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
|
||||
|
||||
/**
|
||||
* Hotwire Native Bridge Controller
|
||||
*
|
||||
* This controller connects your web application to the Hotwire Native app
|
||||
* by extending the BridgeComponent from hotwire-native-bridge.
|
||||
*
|
||||
* For more information, visit: https://native.hotwired.dev/reference/bridge-installation
|
||||
*/
|
||||
export default class extends BridgeComponent {
|
||||
static component = "<?php echo $name; ?>"
|
||||
|
||||
connect() {
|
||||
super.connect()
|
||||
console.debug("Hotwire Native Bridge connected")
|
||||
|
||||
// The bridge is now ready and will handle communication
|
||||
// between your web app and the native application
|
||||
|
||||
// You can add custom event handlers or additional setup here
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect()
|
||||
console.debug("Hotwire Native Bridge disconnected")
|
||||
|
||||
// Clean up any resources or event listeners here
|
||||
}
|
||||
}
|
||||
43
src/Native/src/Twig/NativeExtension.php
Normal file
43
src/Native/src/Twig/NativeExtension.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Twig;
|
||||
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\UX\Native\EventListener\NativeListener;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class NativeExtension extends AbstractExtension
|
||||
{
|
||||
private RequestStack $requestStack;
|
||||
|
||||
public function __construct(RequestStack $requestStack)
|
||||
{
|
||||
$this->requestStack = $requestStack;
|
||||
}
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('ux_is_native', [$this, 'isNative']),
|
||||
];
|
||||
}
|
||||
|
||||
public function isNative(): bool
|
||||
{
|
||||
if (null === $request = $this->requestStack->getCurrentRequest()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $request->attributes->get(NativeListener::NATIVE_ATTRIBUTE, false);
|
||||
}
|
||||
}
|
||||
71
src/Native/src/UXNativeBundle.php
Normal file
71
src/Native/src/UXNativeBundle.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native;
|
||||
|
||||
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
|
||||
use Symfony\Component\DependencyInjection\ChildDefinition;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
|
||||
use Symfony\Component\HttpKernel\Bundle\AbstractBundle;
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
|
||||
use Symfony\UX\Native\Maker\MakeNativeBridgeController;
|
||||
|
||||
class UXNativeBundle extends AbstractBundle
|
||||
{
|
||||
protected string $extensionAlias = 'ux_native';
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return \dirname(__DIR__);
|
||||
}
|
||||
|
||||
public function configure(DefinitionConfigurator $definition): void
|
||||
{
|
||||
$definition->rootNode()
|
||||
->children()
|
||||
->scalarNode('output_dir')
|
||||
->defaultNull()
|
||||
->info('Directory where configuration JSON files are written. Defaults to %kernel.project_dir%/public.')
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
}
|
||||
|
||||
public function build(ContainerBuilder $container): void
|
||||
{
|
||||
parent::build($container);
|
||||
|
||||
$container->addCompilerPass(new ConfigurationCompilerPass());
|
||||
|
||||
$container->registerAttributeForAutoconfiguration(AsNativeConfigurationProvider::class, static function (ChildDefinition $definition): void {
|
||||
$definition->addTag('ux_native.configuration_provider');
|
||||
});
|
||||
}
|
||||
|
||||
public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
|
||||
{
|
||||
$outputDir = $config['output_dir'] ?? $builder->getParameter('kernel.project_dir').'/public';
|
||||
$builder->setParameter('ux_native.output_dir', $outputDir);
|
||||
|
||||
$container->import('../config/services.php');
|
||||
|
||||
if ($builder->hasParameter('kernel.debug') && $builder->getParameter('kernel.debug')) {
|
||||
$container->import('../config/debug.php');
|
||||
}
|
||||
|
||||
$container->services()
|
||||
->set('ux.native.make_native_bridge_controller', MakeNativeBridgeController::class)
|
||||
->tag('maker.command')
|
||||
->args(['%kernel.project_dir%'])
|
||||
;
|
||||
}
|
||||
}
|
||||
39
src/Native/tests/AndroidConfigurationFactory.php
Normal file
39
src/Native/tests/AndroidConfigurationFactory.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests;
|
||||
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
|
||||
#[AsNativeConfigurationProvider]
|
||||
final class AndroidConfigurationFactory
|
||||
{
|
||||
#[AsNativeConfiguration('/config/android_v1.json')]
|
||||
public function v1(): Configuration
|
||||
{
|
||||
return new Configuration(
|
||||
settings: [
|
||||
'use_local_db' => false,
|
||||
],
|
||||
rules: [
|
||||
new Rule(
|
||||
patterns: ['/articles/.*'],
|
||||
properties: ['context' => 'default', 'pull_to_refresh_enabled' => false],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/Native/tests/AppKernel.php
Normal file
44
src/Native/tests/AppKernel.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Component\Config\Loader\LoaderInterface;
|
||||
use Symfony\Component\HttpKernel\Bundle\BundleInterface;
|
||||
use Symfony\Component\HttpKernel\Kernel;
|
||||
use Symfony\UX\Native\UXNativeBundle;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class AppKernel extends Kernel
|
||||
{
|
||||
public function __construct(string $environment)
|
||||
{
|
||||
parent::__construct($environment, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BundleInterface[]
|
||||
*/
|
||||
public function registerBundles(): array
|
||||
{
|
||||
return [new FrameworkBundle(), new UXNativeBundle()];
|
||||
}
|
||||
|
||||
public function registerContainerConfiguration(LoaderInterface $loader): void
|
||||
{
|
||||
$loader->load(__DIR__.'/config.php');
|
||||
}
|
||||
}
|
||||
68
src/Native/tests/Command/ConfigurationDumperTest.php
Normal file
68
src/Native/tests/Command/ConfigurationDumperTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Command;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\UX\Native\Command\ConfigurationDumper;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
|
||||
final class ConfigurationDumperTest extends TestCase
|
||||
{
|
||||
private string $outputDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->outputDir = sys_get_temp_dir().'/ux_native_dumper_test_'.bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
(new Filesystem())->remove($this->outputDir);
|
||||
}
|
||||
|
||||
public function testExecuteCallsBuildAndReturnsSuccess()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
$builder->add('/config/test.json', new Configuration(
|
||||
settings: ['debug' => true],
|
||||
rules: [new Rule(['.*'], ['context' => 'default'])],
|
||||
));
|
||||
|
||||
$command = new ConfigurationDumper($builder);
|
||||
$commandTester = new CommandTester($command);
|
||||
|
||||
$commandTester->execute([]);
|
||||
|
||||
$commandTester->assertCommandIsSuccessful();
|
||||
self::assertFileExists($this->outputDir.'/config/test.json');
|
||||
self::assertStringContainsString('dumped successfully', $commandTester->getDisplay());
|
||||
}
|
||||
|
||||
public function testExecuteWithNoConfigurations()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
|
||||
$command = new ConfigurationDumper($builder);
|
||||
$commandTester = new CommandTester($command);
|
||||
|
||||
$commandTester->execute([]);
|
||||
|
||||
$commandTester->assertCommandIsSuccessful();
|
||||
self::assertStringContainsString('dumped successfully', $commandTester->getDisplay());
|
||||
}
|
||||
}
|
||||
104
src/Native/tests/Configuration/ConfigurationTest.php
Normal file
104
src/Native/tests/Configuration/ConfigurationTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Configuration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
|
||||
final class ConfigurationTest extends TestCase
|
||||
{
|
||||
public function testToArrayWithAllValues()
|
||||
{
|
||||
$configuration = new Configuration(
|
||||
settings: ['use_local_db' => true, 'cable' => ['script_url' => 'https://example.com/cable.js']],
|
||||
rules: [
|
||||
new Rule(['.*'], ['context' => 'default', 'pull_to_refresh_enabled' => true]),
|
||||
],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'settings' => ['use_local_db' => true, 'cable' => ['script_url' => 'https://example.com/cable.js']],
|
||||
'rules' => [
|
||||
[
|
||||
'patterns' => ['.*'],
|
||||
'properties' => ['context' => 'default', 'pull_to_refresh_enabled' => true],
|
||||
],
|
||||
],
|
||||
], $configuration->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithSettingsOnly()
|
||||
{
|
||||
$configuration = new Configuration(
|
||||
settings: ['use_local_db' => false],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'settings' => ['use_local_db' => false],
|
||||
], $configuration->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithRulesOnly()
|
||||
{
|
||||
$configuration = new Configuration(
|
||||
rules: [
|
||||
new Rule(['.*'], ['context' => 'default']),
|
||||
],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'rules' => [
|
||||
[
|
||||
'patterns' => ['.*'],
|
||||
'properties' => ['context' => 'default'],
|
||||
],
|
||||
],
|
||||
], $configuration->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithNoValues()
|
||||
{
|
||||
$configuration = new Configuration();
|
||||
|
||||
self::assertSame([], $configuration->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithMultipleRules()
|
||||
{
|
||||
$configuration = new Configuration(
|
||||
rules: [
|
||||
new Rule(['/articles/.*'], ['context' => 'default']),
|
||||
new Rule(['/admin/.*'], ['context' => 'modal']),
|
||||
new Rule(properties: ['pull_to_refresh_enabled' => false]),
|
||||
],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'rules' => [
|
||||
[
|
||||
'patterns' => ['/articles/.*'],
|
||||
'properties' => ['context' => 'default'],
|
||||
],
|
||||
[
|
||||
'patterns' => ['/admin/.*'],
|
||||
'properties' => ['context' => 'modal'],
|
||||
],
|
||||
[
|
||||
'properties' => ['pull_to_refresh_enabled' => false],
|
||||
],
|
||||
],
|
||||
], $configuration->toArray());
|
||||
}
|
||||
}
|
||||
62
src/Native/tests/Configuration/RuleTest.php
Normal file
62
src/Native/tests/Configuration/RuleTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Configuration;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
|
||||
final class RuleTest extends TestCase
|
||||
{
|
||||
public function testToArrayWithAllValues()
|
||||
{
|
||||
$rule = new Rule(
|
||||
patterns: ['/articles/.*', '/pages/.*'],
|
||||
properties: ['context' => 'default', 'pull_to_refresh_enabled' => true],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'patterns' => ['/articles/.*', '/pages/.*'],
|
||||
'properties' => ['context' => 'default', 'pull_to_refresh_enabled' => true],
|
||||
], $rule->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithPatternsOnly()
|
||||
{
|
||||
$rule = new Rule(
|
||||
patterns: ['.*'],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'patterns' => ['.*'],
|
||||
], $rule->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithPropertiesOnly()
|
||||
{
|
||||
$rule = new Rule(
|
||||
properties: ['context' => 'modal'],
|
||||
);
|
||||
|
||||
self::assertSame([
|
||||
'properties' => ['context' => 'modal'],
|
||||
], $rule->toArray());
|
||||
}
|
||||
|
||||
public function testToArrayWithNoValues()
|
||||
{
|
||||
$rule = new Rule();
|
||||
|
||||
self::assertSame([], $rule->toArray());
|
||||
}
|
||||
}
|
||||
123
src/Native/tests/ConfigurationBuilderTest.php
Normal file
123
src/Native/tests/ConfigurationBuilderTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
|
||||
final class ConfigurationBuilderTest extends TestCase
|
||||
{
|
||||
private string $outputDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->outputDir = sys_get_temp_dir().'/ux_native_test_'.bin2hex(random_bytes(4));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
(new Filesystem())->remove($this->outputDir);
|
||||
}
|
||||
|
||||
public function testAddAndHas()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
$configuration = new Configuration(settings: ['use_local_db' => true]);
|
||||
|
||||
self::assertFalse($builder->has('/config/ios_v1.json'));
|
||||
|
||||
$builder->add('/config/ios_v1.json', $configuration);
|
||||
|
||||
self::assertTrue($builder->has('/config/ios_v1.json'));
|
||||
}
|
||||
|
||||
public function testHasReturnsFalseForUnknownPath()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
|
||||
self::assertFalse($builder->has('/config/unknown.json'));
|
||||
}
|
||||
|
||||
public function testGet()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
$configuration = new Configuration(settings: ['use_local_db' => true]);
|
||||
$builder->add('/config/ios_v1.json', $configuration);
|
||||
|
||||
$result = $builder->get('/config/ios_v1.json');
|
||||
|
||||
self::assertSame($configuration, $result);
|
||||
}
|
||||
|
||||
public function testGetThrowsExceptionForUnknownPath()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
|
||||
$this->expectException(FileNotFoundException::class);
|
||||
$this->expectExceptionMessage('Configuration not found for path: "/config/unknown.json".');
|
||||
|
||||
$builder->get('/config/unknown.json');
|
||||
}
|
||||
|
||||
public function testBuild()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
$builder->add('/config/ios_v1.json', new Configuration(
|
||||
settings: ['use_local_db' => true],
|
||||
rules: [new Rule(['.*'], ['context' => 'default'])],
|
||||
));
|
||||
$builder->add('/config/android_v1.json', new Configuration(
|
||||
settings: ['use_local_db' => false],
|
||||
));
|
||||
|
||||
$builder->build();
|
||||
|
||||
self::assertFileExists($this->outputDir.'/config/ios_v1.json');
|
||||
self::assertFileExists($this->outputDir.'/config/android_v1.json');
|
||||
}
|
||||
|
||||
public function testBuildEncodesJsonCorrectly()
|
||||
{
|
||||
$builder = new ConfigurationBuilder($this->outputDir);
|
||||
$builder->add('/config/ios_v1.json', new Configuration(
|
||||
settings: ['url' => 'https://example.com/path', 'label' => 'été'],
|
||||
rules: [new Rule(['.*'], ['context' => 'default'])],
|
||||
));
|
||||
|
||||
$builder->build();
|
||||
|
||||
self::assertStringEqualsFile($this->outputDir.'/config/ios_v1.json', <<<JSON
|
||||
{
|
||||
"settings": {
|
||||
"url": "https://example.com/path",
|
||||
"label": "été"
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"patterns": [
|
||||
".*"
|
||||
],
|
||||
"properties": {
|
||||
"context": "default"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON);
|
||||
}
|
||||
}
|
||||
135
src/Native/tests/ConfigurationCompilerPassTest.php
Normal file
135
src/Native/tests/ConfigurationCompilerPassTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\DependencyInjection\Definition;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
use Symfony\UX\Native\ConfigurationCompilerPass;
|
||||
use Symfony\UX\Native\Tests\Fixtures\InvalidReturnTypeConfigurationProvider;
|
||||
use Symfony\UX\Native\Tests\Fixtures\StaticMethodConfigurationProvider;
|
||||
|
||||
final class ConfigurationCompilerPassTest extends TestCase
|
||||
{
|
||||
public function testEarlyReturnWhenNoConfigurationBuilder()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$pass = new ConfigurationCompilerPass();
|
||||
|
||||
// Should not throw any exception
|
||||
$pass->process($container);
|
||||
|
||||
self::assertFalse($container->hasDefinition('.ux_native.configuration_builder'));
|
||||
}
|
||||
|
||||
public function testThrowsExceptionWhenPathIsMissing()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
|
||||
|
||||
$definition = new Definition(\stdClass::class);
|
||||
$definition->addTag('ux_native.configuration', []);
|
||||
$container->setDefinition('test.config_without_path', $definition);
|
||||
|
||||
$pass = new ConfigurationCompilerPass();
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The path is missing for "test.config_without_path"');
|
||||
|
||||
$pass->process($container);
|
||||
}
|
||||
|
||||
public function testThrowsExceptionForStaticMethod()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
|
||||
|
||||
$definition = new Definition(StaticMethodConfigurationProvider::class);
|
||||
$definition->addTag('ux_native.configuration_provider');
|
||||
$container->setDefinition('test.static_provider', $definition);
|
||||
|
||||
$pass = new ConfigurationCompilerPass();
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The method "Symfony\UX\Native\Tests\Fixtures\StaticMethodConfigurationProvider::v1()" annotated with #[AsNativeConfiguration] must not be static.');
|
||||
|
||||
$pass->process($container);
|
||||
}
|
||||
|
||||
public function testThrowsExceptionForWrongReturnType()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
|
||||
|
||||
$definition = new Definition(InvalidReturnTypeConfigurationProvider::class);
|
||||
$definition->addTag('ux_native.configuration_provider');
|
||||
$container->setDefinition('test.invalid_return_type_provider', $definition);
|
||||
|
||||
$pass = new ConfigurationCompilerPass();
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('The method "Symfony\UX\Native\Tests\Fixtures\InvalidReturnTypeConfigurationProvider::v1()" annotated with #[AsNativeConfiguration] must have a return type of "Symfony\UX\Native\Configuration\Configuration".');
|
||||
|
||||
$pass->process($container);
|
||||
}
|
||||
|
||||
public function testRegistersAttributeConfigurations()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
|
||||
|
||||
$definition = new Definition(AndroidConfigurationFactory::class);
|
||||
$definition->addTag('ux_native.configuration_provider');
|
||||
$container->setDefinition('test.android_provider', $definition);
|
||||
|
||||
$pass = new ConfigurationCompilerPass();
|
||||
$pass->process($container);
|
||||
|
||||
// The child definition should be registered
|
||||
$childId = '.ux_native.configuration.AndroidConfigurationFactory.v1';
|
||||
self::assertTrue($container->hasDefinition($childId));
|
||||
|
||||
$childDefinition = $container->getDefinition($childId);
|
||||
self::assertTrue($childDefinition->hasTag('ux_native.configuration'));
|
||||
|
||||
$tags = $childDefinition->getTag('ux_native.configuration');
|
||||
self::assertSame('/config/android_v1.json', $tags[0]['path']);
|
||||
|
||||
// The builder should have a method call registered for the tag
|
||||
$builderDefinition = $container->getDefinition('.ux_native.configuration_builder');
|
||||
$methodCalls = $builderDefinition->getMethodCalls();
|
||||
self::assertNotEmpty($methodCalls);
|
||||
self::assertSame('add', $methodCalls[0][0]);
|
||||
}
|
||||
|
||||
public function testRegistersManualTaggedConfigurations()
|
||||
{
|
||||
$container = new ContainerBuilder();
|
||||
$container->setDefinition('.ux_native.configuration_builder', new Definition(ConfigurationBuilder::class, ['/tmp']));
|
||||
|
||||
$definition = new Definition(\stdClass::class);
|
||||
$definition->addTag('ux_native.configuration', ['path' => '/config/ios_v1.json']);
|
||||
$container->setDefinition('test.ios_config', $definition);
|
||||
|
||||
$pass = new ConfigurationCompilerPass();
|
||||
$pass->process($container);
|
||||
|
||||
$builderDefinition = $container->getDefinition('.ux_native.configuration_builder');
|
||||
$methodCalls = $builderDefinition->getMethodCalls();
|
||||
self::assertCount(1, $methodCalls);
|
||||
self::assertSame('add', $methodCalls[0][0]);
|
||||
self::assertSame('/config/ios_v1.json', $methodCalls[0][1][0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\EventListener;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
use Symfony\UX\Native\ConfigurationBuilder;
|
||||
use Symfony\UX\Native\EventListener\DevServerEventListener;
|
||||
|
||||
final class DevServerEventListenerTest extends TestCase
|
||||
{
|
||||
public function testServesConfigurationForMatchingPath()
|
||||
{
|
||||
$builder = new ConfigurationBuilder(sys_get_temp_dir());
|
||||
$builder->add('/config/ios_v1.json', new Configuration(
|
||||
settings: ['use_local_db' => true],
|
||||
rules: [new Rule(['.*'], ['context' => 'default'])],
|
||||
));
|
||||
|
||||
$listener = new DevServerEventListener($builder);
|
||||
$request = Request::create('/config/ios_v1.json');
|
||||
$event = $this->createRequestEvent($request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($event->hasResponse());
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(JsonResponse::class, $response);
|
||||
$data = json_decode($response->getContent(), true, 512, \JSON_THROW_ON_ERROR);
|
||||
self::assertSame(['use_local_db' => true], $data['settings']);
|
||||
self::assertTrue($event->isPropagationStopped());
|
||||
}
|
||||
|
||||
public function testDoesNothingForNonMatchingPath()
|
||||
{
|
||||
$builder = new ConfigurationBuilder(sys_get_temp_dir());
|
||||
$builder->add('/config/ios_v1.json', new Configuration(settings: ['use_local_db' => true]));
|
||||
|
||||
$listener = new DevServerEventListener($builder);
|
||||
$request = Request::create('/config/unknown.json');
|
||||
$event = $this->createRequestEvent($request, HttpKernelInterface::MAIN_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
self::assertFalse($event->isPropagationStopped());
|
||||
}
|
||||
|
||||
public function testIgnoresSubRequests()
|
||||
{
|
||||
$builder = new ConfigurationBuilder(sys_get_temp_dir());
|
||||
$builder->add('/config/ios_v1.json', new Configuration(settings: ['use_local_db' => true]));
|
||||
|
||||
$listener = new DevServerEventListener($builder);
|
||||
$request = Request::create('/config/ios_v1.json');
|
||||
$event = $this->createRequestEvent($request, HttpKernelInterface::SUB_REQUEST);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($event->hasResponse());
|
||||
self::assertFalse($event->isPropagationStopped());
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request, int $requestType): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent($kernel, $request, $requestType);
|
||||
}
|
||||
}
|
||||
75
src/Native/tests/EventListener/NativeListenerTest.php
Normal file
75
src/Native/tests/EventListener/NativeListenerTest.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\EventListener;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\UX\Native\EventListener\NativeListener;
|
||||
|
||||
final class NativeListenerTest extends TestCase
|
||||
{
|
||||
public function testSetsNativeAttributeToTrueWithHotwireNativeUserAgent()
|
||||
{
|
||||
$listener = new NativeListener();
|
||||
$request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Hotwire Native']);
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
|
||||
}
|
||||
|
||||
public function testSetsNativeAttributeToTrueWithHotwireNativeInLongerUserAgent()
|
||||
{
|
||||
$listener = new NativeListener();
|
||||
$request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Turbo Native iOS; Hotwire Native Android']);
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertTrue($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
|
||||
}
|
||||
|
||||
public function testSetsNativeAttributeToFalseWithStandardUserAgent()
|
||||
{
|
||||
$listener = new NativeListener();
|
||||
$request = Request::create('/', server: ['HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)']);
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
|
||||
}
|
||||
|
||||
public function testSetsNativeAttributeToFalseWithEmptyUserAgent()
|
||||
{
|
||||
$listener = new NativeListener();
|
||||
$request = Request::create('/');
|
||||
$request->headers->remove('User-Agent');
|
||||
$event = $this->createRequestEvent($request);
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertFalse($request->attributes->get(NativeListener::NATIVE_ATTRIBUTE));
|
||||
}
|
||||
|
||||
private function createRequestEvent(Request $request, int $requestType = HttpKernelInterface::MAIN_REQUEST): RequestEvent
|
||||
{
|
||||
$kernel = $this->createMock(HttpKernelInterface::class);
|
||||
|
||||
return new RequestEvent($kernel, $request, $requestType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Fixtures;
|
||||
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
|
||||
|
||||
#[AsNativeConfigurationProvider]
|
||||
final class InvalidReturnTypeConfigurationProvider
|
||||
{
|
||||
#[AsNativeConfiguration('/config/invalid.json')]
|
||||
public function v1(): array
|
||||
{
|
||||
return ['invalid' => true];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Fixtures;
|
||||
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfiguration;
|
||||
use Symfony\UX\Native\Attribute\AsNativeConfigurationProvider;
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
|
||||
#[AsNativeConfigurationProvider]
|
||||
final class StaticMethodConfigurationProvider
|
||||
{
|
||||
#[AsNativeConfiguration('/config/static.json')]
|
||||
public static function v1(): Configuration
|
||||
{
|
||||
return new Configuration(settings: ['static' => true]);
|
||||
}
|
||||
}
|
||||
105
src/Native/tests/Functional/CompileCommandTest.php
Normal file
105
src/Native/tests/Functional/CompileCommandTest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Functional;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
final class CompileCommandTest extends WebTestCase
|
||||
{
|
||||
public static function testTheFilesAreCompiledWithTheCommand(): void
|
||||
{
|
||||
// Given
|
||||
static::cleanup();
|
||||
$application = new Application(self::bootKernel());
|
||||
$command = $application->find('ux-native:dump');
|
||||
$commandTester = new CommandTester($command);
|
||||
|
||||
// When
|
||||
$commandTester->execute([]);
|
||||
|
||||
// Then
|
||||
$commandTester->assertCommandIsSuccessful();
|
||||
static::assertFileExists(self::$kernel->getCacheDir().'/output/config/ios_v1.json');
|
||||
static::cleanup();
|
||||
}
|
||||
|
||||
public static function testTheFilesAreCompiledFromAttribute(): void
|
||||
{
|
||||
// Given
|
||||
static::cleanup();
|
||||
$application = new Application(self::bootKernel());
|
||||
$command = $application->find('ux-native:dump');
|
||||
$commandTester = new CommandTester($command);
|
||||
|
||||
// When
|
||||
$commandTester->execute([]);
|
||||
|
||||
// Then
|
||||
$commandTester->assertCommandIsSuccessful();
|
||||
static::assertFileExists(self::$kernel->getCacheDir().'/output/config/android_v1.json');
|
||||
|
||||
$content = file_get_contents(self::$kernel->getCacheDir().'/output/config/android_v1.json');
|
||||
static::assertStringContainsString('"use_local_db": false', $content);
|
||||
static::assertStringContainsString('/articles/.*', $content);
|
||||
static::cleanup();
|
||||
}
|
||||
|
||||
public static function testTheFilesAreServedInDev(): void
|
||||
{
|
||||
// Given
|
||||
$client = self::createClient();
|
||||
static::cleanup();
|
||||
$application = new Application($client->getKernel());
|
||||
$command = $application->find('ux-native:dump');
|
||||
$commandTester = new CommandTester($command);
|
||||
$commandTester->execute([]);
|
||||
|
||||
// When
|
||||
$client->request(Request::METHOD_GET, '/config/ios_v1.json');
|
||||
|
||||
// Then
|
||||
$response = $client->getResponse();
|
||||
static::assertResponseIsSuccessful();
|
||||
static::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
static::assertStringContainsString('"use_local_db":true', $response->getContent());
|
||||
}
|
||||
|
||||
public static function testTheAttributeConfigurationsAreServedInDev(): void
|
||||
{
|
||||
// Given
|
||||
$client = self::createClient();
|
||||
static::cleanup();
|
||||
|
||||
// When
|
||||
$client->request(Request::METHOD_GET, '/config/android_v1.json');
|
||||
|
||||
// Then
|
||||
$response = $client->getResponse();
|
||||
static::assertResponseIsSuccessful();
|
||||
static::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
static::assertStringContainsString('"use_local_db":false', $response->getContent());
|
||||
static::assertStringContainsString('pull_to_refresh_enabled', $response->getContent());
|
||||
}
|
||||
|
||||
private static function cleanup(): void
|
||||
{
|
||||
/** @var Filesystem $filesystem */
|
||||
$filesystem = self::getContainer()->get(Filesystem::class);
|
||||
$filesystem->remove(\sprintf('%s/output', self::$kernel->getCacheDir()));
|
||||
}
|
||||
}
|
||||
36
src/Native/tests/IosConfigurationFactory.php
Normal file
36
src/Native/tests/IosConfigurationFactory.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests;
|
||||
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Configuration\Rule;
|
||||
|
||||
final class IosConfigurationFactory
|
||||
{
|
||||
public static function v1(): Configuration
|
||||
{
|
||||
return new Configuration(
|
||||
[
|
||||
'use_local_db' => true,
|
||||
'cable' => ['script_url' => 'https://hotwire-native-demo.dev/configurations/action_cable.js'],
|
||||
],
|
||||
[
|
||||
new Rule(
|
||||
['.*'],
|
||||
['context' => 'default', 'pull_to_refresh_enabled' => true]
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
79
src/Native/tests/Twig/NativeExtensionTest.php
Normal file
79
src/Native/tests/Twig/NativeExtensionTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\UX\Native\Tests\Twig;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\UX\Native\EventListener\NativeListener;
|
||||
use Symfony\UX\Native\Twig\NativeExtension;
|
||||
|
||||
final class NativeExtensionTest extends TestCase
|
||||
{
|
||||
public function testIsNativeReturnsTrueWhenAttributeIsTrue()
|
||||
{
|
||||
$request = Request::create('/');
|
||||
$request->attributes->set(NativeListener::NATIVE_ATTRIBUTE, true);
|
||||
|
||||
$extension = $this->createExtension($request);
|
||||
|
||||
self::assertTrue($extension->isNative());
|
||||
}
|
||||
|
||||
public function testIsNativeReturnsFalseWhenAttributeIsFalse()
|
||||
{
|
||||
$request = Request::create('/');
|
||||
$request->attributes->set(NativeListener::NATIVE_ATTRIBUTE, false);
|
||||
|
||||
$extension = $this->createExtension($request);
|
||||
|
||||
self::assertFalse($extension->isNative());
|
||||
}
|
||||
|
||||
public function testIsNativeReturnsFalseWhenNoRequest()
|
||||
{
|
||||
$extension = $this->createExtension(null);
|
||||
|
||||
self::assertFalse($extension->isNative());
|
||||
}
|
||||
|
||||
public function testIsNativeReturnsFalseWhenAttributeNotSet()
|
||||
{
|
||||
$request = Request::create('/');
|
||||
|
||||
$extension = $this->createExtension($request);
|
||||
|
||||
self::assertFalse($extension->isNative());
|
||||
}
|
||||
|
||||
public function testGetFunctionsRegistersUxIsNative()
|
||||
{
|
||||
$extension = $this->createExtension(null);
|
||||
|
||||
$functions = $extension->getFunctions();
|
||||
$functionNames = array_map(static fn ($f) => $f->getName(), $functions);
|
||||
|
||||
self::assertContains('ux_is_native', $functionNames);
|
||||
}
|
||||
|
||||
private function createExtension(?Request $request): NativeExtension
|
||||
{
|
||||
$requestStack = new RequestStack();
|
||||
if (null !== $request) {
|
||||
$requestStack->push($request);
|
||||
}
|
||||
|
||||
return new NativeExtension($requestStack);
|
||||
}
|
||||
}
|
||||
19
src/Native/tests/autoload.php
Normal file
19
src/Native/tests/autoload.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
use Symfony\Component\ErrorHandler\ErrorHandler;
|
||||
|
||||
require_once __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// @see https://github.com/symfony/symfony/issues/53812
|
||||
ErrorHandler::register(null, false);
|
||||
58
src/Native/tests/config.php
Normal file
58
src/Native/tests/config.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
|
||||
use Symfony\UX\Native\Configuration\Configuration;
|
||||
use Symfony\UX\Native\Tests\AndroidConfigurationFactory;
|
||||
use Symfony\UX\Native\Tests\IosConfigurationFactory;
|
||||
|
||||
return static function (ContainerConfigurator $container) {
|
||||
$services = $container
|
||||
->services()
|
||||
->defaults()
|
||||
->autowire()
|
||||
->autoconfigure()
|
||||
;
|
||||
|
||||
$container->parameters()->set('kernel.debug', true);
|
||||
|
||||
$container->import(__DIR__.'/../config/services.php');
|
||||
$container->import(__DIR__.'/../config/debug.php');
|
||||
|
||||
$container->extension('ux_native', [
|
||||
'output_dir' => '%kernel.cache_dir%/output',
|
||||
]);
|
||||
$container->extension('framework', [
|
||||
'test' => true,
|
||||
'secret' => 'test',
|
||||
'http_method_override' => true,
|
||||
'handle_all_throwables' => true,
|
||||
'assets' => [
|
||||
'enabled' => true,
|
||||
],
|
||||
'default_locale' => 'en',
|
||||
'php_errors' => [
|
||||
'log' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$services->set(IosConfigurationFactory::class);
|
||||
$services->set('.ux_native.config.ios_v1', Configuration::class)
|
||||
->factory([IosConfigurationFactory::class, 'v1'])
|
||||
->tag('ux_native.configuration', ['path' => '/config/ios_v1.json'])
|
||||
;
|
||||
|
||||
// AndroidConfigurationFactory uses #[AsNativeConfiguration] attribute on methods
|
||||
$services->set(AndroidConfigurationFactory::class);
|
||||
};
|
||||
Reference in New Issue
Block a user