EXT:storybook - Getting Started 

TYPO3

This is the Getting Started guide for the Storybook TYPO3 extension. It will help you to get started with Storybook and TYPO3 Fluid components. It is designed to be a quick introduction to the most important concepts and features of Storybook.

Why and Why Not? 

Learn about component-based development and why Storybook is a great tool for it.

First Fluid Component 

Learn how to write your first Fluid component.

Asset Handling 

Learn how you can handle assets (JS/CSS) in your Components and Storybook.

Installation 

Learn how to install this extension and Storybook.

ViteIntegration 

Learn how to use storybook alongside your Vite setup.

First Storybook Story 

Learn how to write your first Storybook story.

Transformers 

Learn how to use transformers to document more complex arguments in your stories.

Why and Why Not? 

What is Component based development 

Advantages of component based development 🌼 

  • allows for reusability of components (e.g., buttons, forms, modals)
  • promotes separation of concerns (e.g., UI components, business logic, data handling)
  • enables easier testing and maintenance (e.g., testing individual components in isolation)
  • facilitates collaboration among developers (e.g., different teams can work on different components)
  • enhances scalability of applications (e.g., adding new features without affecting existing ones)
  • improves performance by loading only necessary components (if js and css are split)

Challenges of component based development ⚠️ 

  • requires a shift in mindset from traditional monolithic development
  • may introduce complexity in managing dependencies between components
  • can lead to over-engineering if not done carefully (e.g., creating too many small components)
  • requires careful planning and design to ensure components are reusable and maintainable
  • may require additional tooling or frameworks to manage components effectively (e.g., Storybook, component libraries)

additional Information can be found here: TYPO3 Components

Storybook as a tool for component based development 

Advantages of using Storybook 🌼 

  • provides a visual interface for developing and testing components in isolation
  • allows for easy documentation of components (e.g., usage examples, arguments, slots)
  • enables collaboration among developers and designers (e.g., sharing components, feedback)
  • integrates with various testing tools (e.g., playwright)
  • supports addons for additional functionality (e.g., accessibility checks, theming)

Challenges of using Storybook ⚠️ 

  • requires additional setup and configuration (e.g., installing dependencies, configuring addons)
  • may introduce a learning curve for developers unfamiliar with the tool
  • can become complex if not organized properly (e.g., managing multiple stories, addons)
  • requires maintenance to keep stories up-to-date with component changes
  • may not be suitable for all types of components (e.g., complex components with heavy business logic)

additional Information can be found here: Why Storybook

Write your First Fluid Component 

  1. create a ComponentCollection

    create a Class in your extension eg. Classes/ComponentCollection.php:

    Classes/ComponentCollection.php
    <?php
    
    declare(strict_types=1);
    
    namespace Andersundsehr\DummyExtension;
    
    use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
    use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
    use TYPO3Fluid\Fluid\Core\Component\AbstractComponentCollection;
    use TYPO3Fluid\Fluid\View\TemplatePaths;
    
    #[Autoconfigure(public: true)]
    final class ComponentCollection extends AbstractComponentCollection
    {
        public function getTemplatePaths(): TemplatePaths
        {
            $templatePaths = new TemplatePaths();
            $templatePaths->setTemplateRootPaths([
                ExtensionManagementUtility::extPath('dummy_extension', 'Components'),
            ]);
            return $templatePaths;
        }
    }
    
    Copied!
  2. create the Fluid Component

    create a Fluid component in your extension eg. Components/Card/Card.html:

    Components/Card/Card.html
    <f:argument name="title" type="string" description="The title of the card" />
    <f:argument name="text" type="string" description="The main text of the card" />
    <f:argument name="link" type="string" description="Typolink parameter" />
    
    <a href="{f:uri.typolink(parameter: link)}" class="card">
      <h1 class="card__title">{title}</h1>
      <p class="card__text">{text}</p>
      <span class="card__moreButton">more</span>
    </a>
    
    <f:asset.css identifier="card">
      .card {
        display: block;
        padding: 1rem;
        border: 1px solid #ccc;
        border-radius: 4px;
        text-decoration: none;
        color: inherit;
      }
      .card__title {
        font-size: 1.5rem;
        margin-bottom: 0.5rem;
      }
      .card__text {
        font-size: 1rem;
        margin-bottom: 1rem;
      }
      .card__moreButton {
        display: inline-block;
        padding: 0.5rem 1rem;
        background-color: #007bff;
        color: white;
        border-radius: 4px;
        text-decoration: none;
      }
    </f:asset.css>
    
    Copied!
  3. you now have created your first Fluid component

    you can use this component in any Fluid template like this:

    <html
      xmlns:de="http://typo3.org/ns/Andersundsehr/DummyExtension/ComponentCollection"
      data-namespace-typo3-fluid="true"
    >
    
    <de:card
      title="Yar Pirate Ipsum"
      text="Prow scuttle parrel provost Sail ho shrouds spirits boom mizzenmast yardarm. Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl. Swab barque interloper chantey doubloon starboard grog black jack gangway rutters."
      link="https://www.andersundsehr.com"
    />
    Copied!
  4. register the ComponentCollection as global Fluid namespace

    in your ext_localconf.php file, register the ComponentCollection as global Fluid namespace:

    ext_localconf.php
    <?php
    
    declare(strict_types=1);
    
    use Andersundsehr\DummyExtension\ComponentCollection;
    
    # This is required so EXT:storybook can find the component collection
    $GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['namespaces']['de'][] = ComponentCollection::class;
    
    Copied!

How to handle Assets (JS/CSS) in Storybook 

Option 1: Import JS/CSS in your template 

The best option is to use the AssetCollector eg. f:asset.* in your components HTML. This allows you to integrate your JavaScript and CSS files directly into your components without needing to import them in your stories file.

Component/Card/Card.js
<f:asset.css identifier="EXT:my_extension/Component/Card/Card.css" href="EXT:my_extension/Component/Card/Card.css" inline="{true}"/>
<f:asset.script type="module" identifier="EXT:my_extension/Component/Card/Card.js" src="EXT:my_extension/Component/Card/Card.js" inline="{true}"/>
Copied!

Option 2: Integrate JS/CSS in your template 

Alternative is to import your JavaScript and CSS files directly in your stories file.

Component/Card/Card.js
<f:asset.css identifier="EXT:my_extension/Component/Card/Card.css">
  .your-css-class {
    color: red;
  }
</f:asset.css>
<f:asset.script type="module" identifier="EXT:my_extension/Component/Card/Card.js">
  console.log('This is a script for the Card component an is only once in the HTML');
</f:asset.script>
Copied!

Option 3: Auto import JS/CSS in your ComponentCollection 

Alternatively, you can also auto import your JavaScript and CSS files inside your ComponentCollection class:

Classes/ComponentCollection.php
#[Autoconfigure(public: true)]
final class ComponentCollection extends AbstractComponentCollection
{
    public function __construct(private readonly AssetCollector $assetCollector)
    {
    }

    #[Override]
    public function getAdditionalVariables(string $viewHelperName): array
    {
        $templateName = $this->resolveTemplateName($viewHelperName);
        $fileName = $this->getTemplatePaths()->resolveTemplateFileForControllerAndActionAndFormat('Default', $templateName);
        $jsFile = str_replace('.html', '.js', $fileName);
        if (file_exists($jsFile)) {
            $this->assetCollector->addInlineJavaScript(
                self::class . ':' . $viewHelperName,
                file_get_contents($jsFile),
                ['type' => 'module'],
            );
        }
        $cssFile = str_replace('.html', '.css', $fileName);
        if (file_exists($cssFile)) {
            $this->assetCollector->addInlineStyleSheet(
                self::class . ':' . $viewHelperName,
                file_get_contents($cssFile),
            );
        }

        return [];
    }
}
Copied!

Installation of EXT:storybook and storybook 

  1. install EXT:storybook

    installation of EXT:storybook is done via composer:

    install via composer
    composer req andersundsehr/storybook --dev
    Copied!

    It is also possible to install EXT:storybook in legacy mode, but this is not recommended. https://extensions.typo3.org/package/storybook

  2. install storybook

    install via npm
    npm install @andersundsehr/storybook-typo3 --save-dev
    # or
    yarn add @andersundsehr/storybook-typo3 --dev
    Copied!
  3. create /.storybook/ directory

    create a .storybook directory besides your package.json file.

    you need to update your STORYBOOK_TYPO3_ENDPOINT to your TYPO3 instance URL:

    .storybook/main.ts
    import type { StorybookConfig } from '@andersundsehr/storybook-typo3';
    
    const config: StorybookConfig = {
      framework: '@andersundsehr/storybook-typo3',
    
      stories: [
        "../src/**/*.@(mdx|stories.@(mdx|js|jsx|mjs|ts|tsx))",
      ],
    
      core: {
        disableTelemetry: true,
      },
    
      env: (envs) => {
        return {
          STORYBOOK_TYPO3_ENDPOINT: 'http://localhost:8011/_storybook/', // if you use DDEV: process.env.DDEV_PRIMARY_URL
          STORYBOOK_TYPO3_WATCH_ONLY_STORIES: '0', // set to '1' If you already use vite in your TYPO3 with HMR
          // do not set your api key here! https://www.deployhq.com/blog/protecting-your-api-keys-a-quick-guide
          ...envs, // envs given to storybook have precedence
        };
      },
    };
    export default config;
    
    Copied!
    .storybook/preview.ts
    import type { Preview } from '@andersundsehr/storybook-typo3';
    
    const preview: Preview = {
      tags: ['autodocs'],
    };
    export default preview;
    
    Copied!
  4. configure package.json

    add the scripts to your package.json file:

    package.json
    {
      "name": "storybook-minimal",
      "scripts": {
        "storybook": "storybook dev -p 8080 --ci",
        "build-storybook": "storybook build -o public/storybook/"
      },
      "dependencies": {
        "@andersundsehr/storybook-typo3": "file:vendor/andersundsehr/storybook/the-npm-package"
      },
      "devDependencies": {
        "@playwright/test": "^1.54.1"
      }
    }
    
    Copied!
  5. DDEV configuration

    if you are using DDEV you need to add the following to your .ddev/config.yaml file:

    .ddev/config.yaml
    web_extra_exposed_ports:
    - name: storybook
      container_port: 8080
      http_port: 8081
      https_port: 8080
    Copied!
  6. # Now you have a working Storybook setup!

    You can now run Storybook with the following command:

    start storybook
    npm run storybook
    # or
    yarn storybook
    Copied!

    This will start the Storybook server. You can than access it in your browser at your configured URL.

    You can now start creating stories for your TYPO3 Fluid components!

Integrate your vite config with Storybook 

Storybook uses Vite under the hood to build and serve your stories. By default, Storybook will use your local Vite configuration if it exists. However, some configurations may be overridden or not compatible with Storybook's setup. This document provides a guide on how to integrate your Vite configuration with Storybook effectively.

Usage with Vite AssetCollector 

If you are using Vite AssetCollector make sure you set the aliases option to EXT.

vite.config.js|ts
import { defineConfig } from 'vite';
import typo3 from 'vite-plugin-typo3';

export default defineConfig({
  plugins: [typo3({ aliases: 'EXT' })],
});
Copied!

Ignore your local Vite config 

Some times it is necessary to ignore your local Vite config in Storybook. You can disable the usage of your local Vite config in Storybook by setting viteConfigPath in the .storybook/main.ts/.js:

https://storybook.js.org/docs/builders/vite#override-the-default-configuration

.storybook/main.ts|js
export default {
  core: {
    builder: {
      name: '@storybook/builder-vite',
      options: {
        // the file needs to exist, and export a (empty) valid vite config
        viteConfigPath: './customVite.config.js',
      },
    },
  },
};
Copied!

Override Storybook's Vite configuration 

Some configurations in your local Vite config may not be compatible with Storybook's setup. Or maybe be overridden by Storybook. You can create a finalVite function in your .storybook/main.ts/.js file to override Storybook's Vite configuration.

.storybook/main.ts|js
import type { StorybookConfig } from '@andersundsehr/storybook-typo3';
import { mergeConfig, type InlineConfig } from 'vite';

export default {
  viteFinal: async (config, option) => {
    return mergeConfig(config, {
      server: {
        allowedHosts: ['.ddev.site'],
        hmr: {
          clientPort: 8080,
          protocol: 'wss',
        },
      },
    } satisfies Partial<InlineConfig>);
  },
};
Copied!

How to handle Assets (JS/CSS) in Storybook 

Option 1: Import JS/CSS in your template 

The best option is to use the AssetCollector eg. f:asset.* in your components HTML. This allows you to integrate your JavaScript and CSS files directly into your components without needing to import them in your stories file.

Component/Card/Card.js
<f:asset.css identifier="EXT:my_extension/Component/Card/Card.css" href="EXT:my_extension/Component/Card/Card.css" inline="{true}"/>
<f:asset.script type="module" identifier="EXT:my_extension/Component/Card/Card.js" src="EXT:my_extension/Component/Card/Card.js" inline="{true}"/>
Copied!

Option 2: Integrate JS/CSS in your template 

Alternative is to import your JavaScript and CSS files directly in your stories file.

Component/Card/Card.js
<f:asset.css identifier="EXT:my_extension/Component/Card/Card.css">
  .your-css-class {
    color: red;
  }
</f:asset.css>
<f:asset.script type="module" identifier="EXT:my_extension/Component/Card/Card.js">
  console.log('This is a script for the Card component an is only once in the HTML');
</f:asset.script>
Copied!

Option 3: Auto import JS/CSS in your ComponentCollection 

Alternatively, you can also auto import your JavaScript and CSS files inside your ComponentCollection class:

Classes/ComponentCollection.php
#[Autoconfigure(public: true)]
final class ComponentCollection extends AbstractComponentCollection
{
    public function __construct(private readonly AssetCollector $assetCollector)
    {
    }

    #[Override]
    public function getAdditionalVariables(string $viewHelperName): array
    {
        $templateName = $this->resolveTemplateName($viewHelperName);
        $fileName = $this->getTemplatePaths()->resolveTemplateFileForControllerAndActionAndFormat('Default', $templateName);
        $jsFile = str_replace('.html', '.js', $fileName);
        if (file_exists($jsFile)) {
            $this->assetCollector->addInlineJavaScript(
                self::class . ':' . $viewHelperName,
                file_get_contents($jsFile),
                ['type' => 'module'],
            );
        }
        $cssFile = str_replace('.html', '.css', $fileName);
        if (file_exists($cssFile)) {
            $this->assetCollector->addInlineStyleSheet(
                self::class . ':' . $viewHelperName,
                file_get_contents($cssFile),
            );
        }

        return [];
    }
}
Copied!

Write your First Storybook Story 

A storybook stories file includes a Meta object and one or more StoryObj objects.

the Meta object defines the component that is used in the story.

the StoryObj object defines the story itself, including the arguments that are passed to the component.

to create your first story you only need to create a file with the Meta and one empty StoryObj.

this is a minimal example of a storybook story file:

Components/Card/Card.stories.ts
import { type Meta, type StoryObj, fetchComponent } from '@andersundsehr/storybook-typo3';

/**
 * This is a simple card component that can be used to display title, text and add a link to that card.
 */
export default {
  component: await fetchComponent('de:card'),
} satisfies Meta;

export const Story1: StoryObj = {
  args: {},
};
Copied!

you only need to change the component name to your Fluid component name, in this case de:card.

Now you can go into your Storybook UI, you will see an error if you have required arguments.

The error is shown in the Storybook UI

You than can go to the Story 1 view and set all required arguments.

After that you can save the updated arguments with the button: ✔️ Update story at the bottom of the page.

The error is shown in the Storybook UI

What are Transformers? 

Transformers are a way to document more complex arguments in your Storybook stories.

The big problem with complex arguments is that they are not easily documented in the Storybook UI. Storybook only supports basic types like string, number, boolean, etc.

The Storybook extension also automaticall creates Selects for PHP enums and sets the Storybook type to select. For DateTime, DateTimeImmutable and DateTimeInterface it creates a date picker.

for all other complex Types we need a Transformer to convert basic types to a more complex type.

TypeTransformer 

The TYPO3 object \TYPO3\CMS\Core\Http\Uri is one of the complex types. As Storybook sends all Objects from JS-Browserland via JSON to PHP, we dont get the native PHP object.

EXT:storybook provides a Transformer for the \TYPO3\CMS\Core\Http\Uri object.

it looks like this:

...

use Andersundsehr\Storybook\Transformer\Attribute\TypeTransformer;
use TYPO3\CMS\Core\Http\Uri;

class ...
{
    #[TypeTransformer(priority: 100)]
    public function uri(string $url): Uri
    {
        return new Uri($url);
    }
}
Copied!

As you can see the Transformers can be really simple. A TypeTransformer is any Function on any Service that has the #[TypeTransformer] attribute. It can take any number of arguments, the argument types need to be basic types like string, int, bool, etc. or DateTime, DateTimeImmutable, DateTimeInterface, or any UnitEnum.

The return type of the function is the complex type that you want to transform to.

All types are required. As that information is used to generate the Storybook UI Controls.

priority of TypeTransformers 

You can order the TypeTransformers by priority, highest number wins.

To debug the TypeTransformers used you can take a look in the TYPO3 Backend Module System -> Configuration and select the EXT:storybook TypeTransformers Configuration. There you can see all registered TypeTransformers and their priorities.

Per Argument Transformers 

Sometimes we don't want to transform the arguments by type but by the argument name.

For that we can define a *.transformers.php file in the same directory as the *.html file.

Components/TransformerExample/TransformerExample.transformer.php
<?php

use Andersundsehr\Storybook\Transformer\ArgumentTransformers;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Resource\File;

return new ArgumentTransformers(
    // uri: uses the TypeTransformer automatically.
    websocket: fn(string $url): Uri => (new Uri($url))->withScheme('wss'),
    contextIcon: fn(ContextualFeedbackSeverity $severity): string => $severity->getIconIdentifier(),
    // TODO add more examples (eg. SysFile/SysFileReference, ContentBlockData)
    combineUri: fn(string $host, string $path, string $query = '', string $fragment = '', string $scheme = 'https'): Uri => new Uri(
        $scheme . '://' . $host . '/' . $path . '?' . $query . '#' . $fragment
    ),
    transformerWithoutArguments: fn(): Uri => new Uri('https://storybook.andersundsehr.com'),
    // file: uses the TypeTransformer automatically.
    // typolink: uses the TypeTransformer automatically.
    // Dependency injection of any public service is possible, so we can inject the ResourceFactory here:
    fileWithDefault: function (ResourceFactory $resourceFactory, string $extPath = 'EXT:storybook/Resources/Public/Icons/AusLogo.svg'): File {
        $file = $resourceFactory->retrieveFileOrFolderObject($extPath);
        assert($file instanceof File, 'The file with the path "' . $extPath . '" could not be resolved to a File object.');
        return $file;
    },
);
Copied!

Dependency Injection for Argument Transformers 

You can inject any public service into the argument transformer. For example see above fileWithDefault. You can inject a service that fetches data from an API to populate your objects.