Bridging Twill and Vue: crafting dynamic content Blocks
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:
Create a Twill Block component and add it to the BlockEditor of our
PageContent
ModuleImprove our Twill Models to compute blocks data
Create a Vue 3 component for the Block
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 aView
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()
andgetForm()
methods, preventing to implement it in every blockOverrides the
getBlockGroup()
helper, that returnsapp-
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
namespaceProvides a dynamic title with
getBlockTitleField()
using thetitle
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 overridingshouldHidePrefix()
method)Customizes the title with
getBlockTitle()
and the icon withgetBlockIcon()
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
andposition
, 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 ourTitle
block, we could define anh1
if position is 1 and anh2
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
Subscribe to my newsletter
Read articles from Codivores directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by