Bridging Twill and Vue: crafting dynamic content Blocks

Bridging Twill and Vue: crafting dynamic content Blocks

·

8 min read

Now that we can create pages, it's time to leverage Twill's powerful content management features and construct reusable blocks.

In this article, we will focus on creating a basic Title Twill Block (with a translatable Twill Text Input in a Common namespace, using the Twill 3 formbuilder) and explore ways to enhance Developer Experience for subsequent blocks.

To achieve this, here are the different steps:

  1. Create a Twill Block component and add it to the BlockEditor of our PageContent Module

  2. Improve our Twill Models to compute blocks data

  3. Create a Vue 3 component for the Block

  4. Use it in our PageContent Inertia Vue page

Twill Block creation

For detailed information, refer to the official documentation.

Generation

A Block can be initiated with the CLI generator. Let's create a Title Block in the Common namespace.

# php artisan twill:make:componentBlock namespace.name
php artisan twill:make:componentBlock common.title

The output we can see in our terminal:

Class written to /var/www/app/View/Components/Twill/Blocks/Common/Title.php
View written to /var/www/resources/views/components/twill/blocks/common/title.blade.php

Files generated

Since the view file is intended for rendering Blocks through Blade, it can be confidently removed.

The component class skeleton is as follows:

/app/View/Components/Twill/Blocks/Common/Title.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Fields\Wysiwyg;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\View\Components\Blocks\TwillBlockComponent;
use Illuminate\Contracts\View\View;

class Title extends TwillBlockComponent
{
    public function render(): View
    {
        return view('components.twill.blocks.common.title');
    }

    public function getForm(): Form
    {
        return Form::make([
            Input::make()->name('title'),
            Wysiwyg::make()->name('text')
        ]);
    }
}

We can see that the Block component main purpose is to define the form to manage its content and how render it.

As we

  • won't use Blade engine for rendering, but we need to implement the render() method and return a View

  • want to have just a translatable Text Input

we can adapt the code this way:

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\View\Components\Blocks\TwillBlockComponent;
use Illuminate\Contracts\View\View;

class Title extends TwillBlockComponent
{
    public function render(): View
    {
        return view();
    }

    public function getForm(): Form
    {
        return Form::make([
            Input::make()
                ->name('title')
                ->label(__('Title'))
                ->translatable(),
        ]);
    }
}

Adding the block to the BlockEditor of our Module

We already created a BlockEditor field in our PageContent Form, clicking on the Add content button, we can see our Title block in the dropdown with default Twill blocks:

Selecting it, a translatable Text Input is displayed.

Twill Block improvements

For now, we have seen the basics of a Block, but there are helpers that improve the experience. As we are going to create many blocks, a little refactoring would not be useless.

Creating a BlockComponent class for our project

/app/View/Components/Twill/Blocks/Base/BlockComponent.php

<?php

namespace App\View\Components\Twill\Blocks\Base;

use A17\Twill\Services\Forms\Form;
use A17\Twill\View\Components\Blocks\TwillBlockComponent;
use Illuminate\Contracts\View\View;

class BlockComponent extends TwillBlockComponent
{
    public function render(): View
    {
        return view();
    }

    public function getForm(): Form
    {
        return Form::make([]);
    }

    public static function getBlockGroup(): string
    {
        return '';
    }
}

What it does

  • Defines default render() and getForm() methods, preventing to implement it in every block

  • Overrides the getBlockGroup() helper, that returns app- by default for every block and does not represent our code organization

We will see later, but this class will allow us, for example, to globally define the default editor and toolbar of WYSIWYG fields, ...

Creating a Base Block class for our namespace

/app/View/Components/Twill/Blocks/Common/Base.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use App\View\Components\Twill\Blocks\Base\BlockComponent;

class Base extends BlockComponent
{
    public static function getBlockGroup(): string
    {
        return 'common-';
    }
}

Its purpose is essentially to define the group for all blocks in the namespace.

And now our Block

/app/View/Components/Twill/Blocks/Common/Base.php

<?php

namespace App\View\Components\Twill\Blocks\Common;

use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Input;

class Title extends Base
{
    public function getForm(): Form
    {
        return Form::make([
            Input::make()
                ->name('title')
                ->label(__('Title'))
                ->translatable(),
        ]);
    }

    public static function getBlockTitleField(): ?string
    {
        return 'title';
    }

    public static function getBlockTitle(): string
    {
        return __('Title');
    }

    public static function getBlockIcon(): string
    {
        return 'wysiwyg_header';
    }
}

What it does

  • Extends our Base Block in our Common namespace

  • Provides a dynamic title with getBlockTitleField() using the title field. It's not mandatory but can be helpful when there are many blocks in the BlockEditor (you can also remove the Block title prefix overriding shouldHidePrefix() method)

  • Customizes the title with getBlockTitle() and the icon with getBlockIcon()

Filtering allowed blocks on our module [optional]

In the BlockEditor field, it is possible to define the list of allowed blocks. Depending on the page templates and the number of blocks in your project, it could be a good practice to define exactly the list of available blocks for each Module.

Taking the controller of our PageContent Module, in the getForm() method, we will call the blocks() method, giving it the list of the blocks.

/app/Http/Controllers/Twill/PageContentController.php

...    

    public function getForm(TwillModelContract $model): Form
    {
        $form = parent::getForm($model);

        $form->add(
            BlockEditor::make()
                ->withoutSeparator()
                ->blocks([
                    'common-title',
                ])
        );

        return $form;
    }

...

Let's take a look on the frontend side

A Twill Block component is a Laravel Eloquent Model and therefore contains a lot of information (attributes, relations, ...) that are not necessary.

Here is what it looks like if we inject the block as is:

Let's create a Base Model with some methods to prepare the Blocks data

We will create a Model that extends Twill's Model in which we will add a method to prepare Block data.

For now, we will just handle the blocks, but this class will also allow us to manage Model medias, files, slug, and also medias, browsers, ... in blocks

/app/Models/Base/Model.php

<?php

namespace App\Models\Base;

use A17\Twill\Models\Model as TwillModel;
use Illuminate\Support\Arr;

class Model extends TwillModel
{
    public function computeBlocks(string $locale = null): void
    {
        $locale = $locale ?? app()->getLocale();
        $fallbackLocale = config('translatable.use_property_fallback', false) ? config('translatable.fallback_locale') : $locale;

        $blocks = $this->blocks
            ->where('parent_id', null)
            ->map(function ($block) use ($locale, $fallbackLocale) {
                $block->childs = $this->blocks
                    ->where('parent_id', $block->id)
                    ->map(function ($blockChild) use ($locale, $fallbackLocale) {
                        $blockChild->childs = $this->blocks
                            ->where('parent_id', $blockChild->id)
                            ->map(function ($blockChildChild) use ($locale, $fallbackLocale) {
                                return $this->computeBlock($blockChildChild, $locale, $fallbackLocale);
                            })
                            ->values();

                        $block = $this->computeBlock($blockChild, $locale, $fallbackLocale);
                        return $block;
                    })
                    ->values();

                $block->unsetRelation('children');

                return $this->computeBlock($block, $locale, $fallbackLocale);
            })->values();

        $this->unsetRelation('blocks');
        $this->blocks = $blocks->values();
    }

    private function computeBlock($block, string $locale, string $fallbackLocale = null): array
    {
        // Handle translated content inputs.
        if (is_array($block->content) && count($block->content) > 0) {
            $blockContent = $block->content;
            foreach ($blockContent as $field => $value) {
                if (is_array($value)) {
                    if (isset($value[$locale]) || isset($value[$fallbackLocale])) {
                        $blockContent[$field] = $block->translatedInput($field);
                    } else {
                        foreach (config('translatable.locales') as $allowedLocale) {
                            if (isset($value[$allowedLocale])) {
                                $blockContent[$field] = null;
                                break;
                            }
                        }
                    }
                }
            }
            $block->content = $blockContent;
        }

        return $block->only(Arr::collapse(
            [
                [
                    'editor_name',
                    'position',
                    'type',
                    'content',
                ],
                ($block->childs && count($block->childs) > 0) ? ['childs'] : []
            ]
        ));
    }
}

Adaptations of the Model of our Module

It must now:

  • extend our Base Model

  • declare the blocks attribute in its $publicAttributes array attribute

/app/Models/PageContent.php

<?php

// use A17\Twill\Models\Model;
use App\Models\Base\Model;

class PageContent extends Model implements Sortable
{
    public array $publicAttributes = [
        'title',
        'meta_title',
        'meta_description',
        'blocks',
    ];
}

Adapt our existing Module controllers

On both Back and Frontend controllers for our Module, we will call the computeBlocks() method on the $item.

/app/Http/Controllers/Admin/PageContentController.php

<?php

...

use App\Models\Base\Model;

class PageContentController extends BaseModuleController
{
    ...

    /**
     * @param Model $item
     * @return array
     */
    protected function previewData($item)
    {

        $item->computeBlocks();


        return $this->previewForInertia($item->only($item->publicAttributes), [
            'page' => 'Page/Content',
        ]);
    }
}

/app/Http/Controllers/App/PageContentController.php

<?php

...

class PageContentController extends Controller
{
    public function show(string $slug): InertiaResponse
    {
        $item = Cache::rememberForever(
            'page.content.' . app()->getLocale() . '.' . $slug,
            function () use ($slug) {
                $item = PageContent::publishedInListings()
                    ->forSlug($slug)
                    ->first();
                if ($item !== null) {
                    $item->load('translations', 'medias', 'blocks');


                    $item->computeBlocks();


                }
                return $item;
            }
        );

        ...
    }
}

Here is what we have now on the Vue side:

We could clean up more by removing editor_name and position, but we decided to keep them because it happens to us to have several BlockEditor fields on a same Module, and also to handle display behaviors depending on the position (for example, for our Title block, we could define an h1 if position is 1 and an h2 otherwise).

Vue Block creation

Now we have a clean and orderer array of blocks that we can process in ou Inertia Page as a Vue Single-File Component (SFC).

Block component

We will create a Vue component that corresponds to the Twill Block.

/resources/views/Components/Theme/Block/Common/Title.vue

<script setup lang="ts">
defineOptions({
  name: 'BlockCommonTitle',
})

interface Props {
  block: Model.Block & PropsBlock
}

type PropsBlock = {
  content: {
    title?: string | null
  }
}

defineProps<Props>()
</script>

<template>
  <h1
    v-if="block.content?.title"
    v-html="block.content.title"
    class="text-center text-4xl font-semibold text-gray-900"
  ></h1>
</template>

TypeScript types enhancement [optional]

We can create a Block type, and add blocks to our Page type:

/resources/js/types/models.d.ts

declare namespace Model {
  export type Page = {
    ...
    blocks?: Array<Block> | null
  }

  export type Block = {
    editor_name: string
    position: number
    type: string
    content: {} | null
  }
}

Adding a @Block alias [optional]

/vite.config.js

import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    alias: [
      {
        find: '@Block',
        replacement: '/resources/views/Components/Theme/Block',
      },

      ...
    ],
  },
})

/tsconfig.json

{
  "compilerOptions": {
    ...

    "paths": {
      "@Block/*": ["resources/views/Components/Theme/Blocks/*"],

      ...
    },

    ...
  }
}

Integration into our Inertia Vue Page

  • We will load our blocks asynchronously, avoiding unnecessary component loading using Vue 3 defineAsyncComponent()

  • In the template, if our item has blocks, we will loop through the list and load the appropriate Vue component based on its type value (v-if="block.type == 'my-block-name'")

/resources/views/Pages/Page/Content.vue

<script setup lang="ts">
import Head from '@Theme/Head.vue'

interface Props {
  item: Model.Page
}

defineProps<Props>()

const BlockCommonTitle = defineAsyncComponent(() => import('@Block/Common/Title.vue'))
</script>

<template>
  <Head :item="item"></Head>

  <div
    v-if="item?.blocks && Array.isArray(item.blocks) && item.blocks.length > 0"
    class="mx-auto w-full max-w-6xl"
  >
    <div
      v-for="(block, index) in item.blocks"
      :key="index"
    >
      <BlockCommonTitle
        v-if="block.type == 'common-title'"
        :block="block"
      ></BlockCommonTitle>
    </div>
  </div>
</template>

The rendering is as follows:


We now have our first Block, we will see in a later article how to create other blocks with medias, links, HTML, ...


We'll do our best to provide source code of the serie on GitHub