Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better Tailwind, Live Components, Args Processors and Component Mock #3

Merged
merged 7 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,57 @@ const config: StorybookConfig = {
};
```

### TailwindBundle integration

If you use [TailwindBundle](https://symfony.com/bundles/TailwindBundle/current/index.html) to manage your CSS, it will
work by default with this bundle thanks to a built-in integration. Everytime your Storybook is recompiled, Tailwind
will also recompile your CSS.

However, each Tailwind build is executed in a one-shot process (without the `--watch` option). It may lead to errors
because the TailwindBundle will always try to download the latest version of the Tailwind binary. After a few builds
you could encounter an error trying to get the latest binary, because you requested the GitHub API too much in a short
period of time.

A good workaround for this is to specify the binary version in the TailwindBundle configuration:

```yaml
# config/packages/tailwind.yaml

symfonycasts_tailwind:
binary_version: v3.4.1
```

### Live Components integration

To make [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html) work in Storybook, you have to enable proxy for live component requests in the
Storybook `main.ts|js` configuration:

```ts
// .storybook/main.ts

// ...

const config: StorybookConfig = {
framework: {
name: "@sensiolabs/storybook-symfony-webpack5",
options: {
// ...
symfony: {
proxyPaths: [
// ...
// 👇 This is the live component prefix usually set in config/routes/ux_live_component.yaml
'_components/',
],
},
},
},
};
```

Thanks to this configuration, all requests made by live components to re-render themselves will be sent to the
Symfony server.


## Writing stories

Example:
Expand Down Expand Up @@ -239,6 +290,190 @@ export const Secondary = {
};

```

## Mocking Twig components

One of the powerful features of Twig components is to use dependency injection to inject services (like Doctrine repositories) and consume them in property getters and other methods. Let's take the [`FeaturedProducts`](https://symfony.com/bundles/ux-twig-component/current/index.html#fetching-services) component from the official documentation:

```php
// src/Twig/Components/FeaturedProducts.php
namespace App\Twig\Components;

use App\Repository\ProductRepository;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
class FeaturedProducts
{
private ProductRepository $productRepository;

public function __construct(ProductRepository $productRepository)
{
$this->productRepository = $productRepository;
}

public function getProducts(): array
{
// an example method that returns an array of Products
return $this->productRepository->findFeatured();
}
}
```

```twig
{# templates/components/FeaturedProducts.html.twig #}
<div>
<h3>Featured Products</h3>

{% for product in this.products %}
...
{% endfor %}
</div>
```

That's pretty cool, but in your Storybook you probably don't want to use the _real_ `getProducts` implementation, which relies on `ProductRepository`. To bypass the original property resolution, you can create a Component Mock:

```php
// src/Storybook/Mock/FeaturedProductsMock.php

namespace App\Storybook\Mock;

use App\Twig\Components\FeaturedProducts;
use Storybook\Attributes\AsComponentMock;
use Storybook\Attributes\PropertyMock;

#[AsComponentMock(component: FeaturedProducts::class)]
class FeaturedProductsMock
{
// Mock 'products' property for all stories:

#[PropertyMock] // property argument is optional and defaults to the annotated method name
public function products()
{
return [
['id' => 0, 'name' => 'Product 1', 'color' => 'Red'],
['id' => 1, 'name' => 'Product 2', 'color' => 'Green'],
];
}

// Or use different implementations for specific stories:

#[PropertyMock(property: 'products', stories: ['featured-products--story1', 'featured-products--story2'])]
public function getFewProducts()
{
return [
['id' => 0, 'name' => 'Product 1', 'color' => 'Red'],
['id' => 1, 'name' => 'Product 2', 'color' => 'Green'],
];
}

#[PropertyMock(property: 'products', stories: 'featured-products--story3')]
public function getALotOfProducts()
{
return [
['id' => 0, 'name' => 'Product 1', 'color' => 'Red'],
['id' => 1, 'name' => 'Product 2', 'color' => 'Green'],
// ...
['id' => 99, 'name' => 'Product 99', 'color' => 'Blue'],
];
}
}
```

As Component Mocks are regular services, you can inject whatever you need, for example to delegate your fixtures management to an external service:

```php
// src/Storybook/Mock/FeaturedProductsMock.php

// ...

#[AsComponentMock(component: FeaturedProducts::class)]
class FeaturedProductsMock
{
public function __construct(private readonly ProductFixturesProvider $fixturesProvider)
{
}

#[PropertyMock]
public function products()
{
return $this->fixturesProvider->getSomeProducts();
}
}
```

If you need to access the original arguments passed to the method, or the original component instance, you can use the
`MockInvocationContext`:

```php
// src/Storybook/Mock/FeaturedProductsMock.php

// ...

use Storybook\Mock\MockInvocationContext;

#[AsComponentMock(component: FeaturedProducts::class)]
class FeaturedProductsMock
{
#[PropertyMock]
public function products(MockInvocationContext $context)
{
$context->component->prop; // Access to the component prop
$context->originalArgs[0]; // Access to the first argument passed to the method
}
}
```


> Note: \
> Mocks will also bypass resolution of [computed properties](https://symfony.com/bundles/ux-twig-component/current/index.html#computed-properties), but be aware that the result will not be cached.

## Processing story args

Story's args are passed a JSON-encoded query parameters to the render request. By default, they are only decoded and injected in the Twig context on template rendering. You can customize these args before they are injected in the context with Args Processors:

```php
// src/Storybook/ArgsProcessor/MyArgsProcessor.php

namespace App\Storybook\ArgsProcessor;

use Storybook\Attributes\AsArgsProcessor;

#[AsArgsProcessor]
class MyArgsProcessor
{
public function __invoke(array &$args): void
{
// Defaults arg 'foo' to 'bar' for all stories
$args += ['foo' => 'bar'];
}
}
```

You can also restrict your processor to a specific subset of stories:

```php
// src/Storybook/ArgsProcessor/MyArgsProcessor.php

namespace App\Storybook\ArgsProcessor;

use Storybook\Attributes\AsArgsProcessor;

#[AsArgsProcessor(story: 'user-list--story1')]
#[AsArgsProcessor(story: 'user-list--story2')]
class MyArgsProcessor
{
public function __invoke(array &$args): void
{
// Transform user's array data to object
foreach ($args['users'] as $key => $user) {
$args['users'][$key] = new User($user['id'], $user['name']);
}
}
}
```


# Troubleshooting

## Conflicting `string-width` module
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
"symfony/framework-bundle": "^7.0",
"symfony/phpunit-bridge": "^7.0",
"symfony/twig-bundle": "^7.0",
"symfony/ux-twig-component": "^2.13"
"symfony/ux-twig-component": "^2.13",
"symfony/css-selector": "^7.0",
"symfony/ux-live-component": "^2.13",
"symfonycasts/tailwind-bundle": "^0.5.0",
"symfonycasts/sass-bundle": "^0.5.1"
}
}
11 changes: 6 additions & 5 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;

return function (RoutingConfigurator $routes) {
$routes->add('storybook_render', '/_storybook/render/{id}')
->requirements([
'id' => '.+',
])
->controller('storybook.controller.render_story')
$routes
->add('storybook_render', '/_storybook/render/{story}')
->requirements([
'story' => '.+',
])
->controller('storybook.controller.render_story')
;
};
4 changes: 0 additions & 4 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,6 @@ parameters:
bootstrapFiles:
- vendor/bin/.phpunit/phpunit/vendor/autoload.php
ignoreErrors:
-
message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface\:\:end\(\)\.$#'
count: 1
path: src/StorybookBundle.php
-
message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface\:\:scalarNode\(\)\.$#'
count: 1
Expand Down
54 changes: 54 additions & 0 deletions src/ArgsProcessor/StorybookArgsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Storybook\ArgsProcessor;

use Storybook\Util\RequestAttributesHelper;
use Storybook\Util\StorybookAttributes;
use Symfony\Component\HttpFoundation\Request;

/**
* @author Nicolas Rigaud <[email protected]>
*/
final class StorybookArgsProcessor
{
/**
* @var array<array{'story': ?string, 'processor': callable}>
*/
private array $processors = [];

public function addProcessor(callable $processor, ?string $story): void
{
$this->processors[] = [
'story' => $story,
'processor' => $processor,
];
}

public function process(Request $request): array
{
$storybookAttributes = RequestAttributesHelper::getStorybookAttributes($request);

$args = $request->query->all();

// Decode JSON args
foreach ($args as $key => $value) {
$decoded = json_decode($value, associative: true);
if (\JSON_ERROR_NONE === json_last_error()) {
$args[$key] = $decoded;
}
}

foreach ($this->processors as ['story' => $story, 'processor' => $processor]) {
if ($this->match($story, $storybookAttributes)) {
$processor($args);
}
}

return $args;
}

private function match(?string $story, StorybookAttributes $storybookAttributes): bool
{
return null === $story || $storybookAttributes->story === $story;
}
}
14 changes: 14 additions & 0 deletions src/Attributes/AsArgsProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Storybook\Attributes;

/**
* @author Nicolas Rigaud <[email protected]>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AsArgsProcessor
{
public function __construct(public readonly ?string $story = null, public readonly int $priority = 0)
{
}
}
20 changes: 20 additions & 0 deletions src/Attributes/AsComponentMock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Storybook\Attributes;

/**
* Registers this class as a mock provider for Storybook rendering.
*
* @author Nicolas Rigaud <[email protected]>
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final class AsComponentMock
{
/**
* @param string $component The component class to mock
*/
public function __construct(
public readonly string $component,
) {
}
}
19 changes: 0 additions & 19 deletions src/Attributes/AsStorybookLoader.php

This file was deleted.

Loading
Loading