Creating custom Vue.js components, form fields and blocks

Objectives:

  • Get an overview of Twill's Artisan commands for development
  • Create a custom Vue.js component
  • Create a custom Twill form field
  • Create a custom Twill block

Part 1 - Setup Twill for development

If you have been working with Twill for some time, you may already be familiar with the php artisan twill:update command. This command takes Twill's precompiled assets (JS, CSS, etc.) and copies them to your project's public/assets/admin folder.

To develop custom Vue.js components for Twill, we can forget about twill:update and take control of the build step with the following commands:

  • twill:build -> Build Twill assets with custom Vue components/blocks
  • twill:dev -> Hot reload Twill assets with custom Vue components/blocks

We'll start by creating a simple HelloWorld component and building it with twill:build.

You will need a module in your project to follow along. We'll use pages as an example in this text.

Create a custom component

Create the following file in your project:

File:

resources/assets/js/components/HelloWorld.vue

1 
2<template>
3 <!-- eslint-disable -->
4 <div>
5 Hello {{ name }}
6 </div>
7</template>
8 
9<script>
10 /* eslint-disable */
11 
12 export default {
13 props: ['name']
14 }
15</script>

Why eslint-disable? Twill's own build tools come with a pretty strict ESLint configuration out of the box. This ensures a good internal consistency in the code of Twill's own components, but will give you an error if your components don't follow Twill's formatting rules.

The eslint-disable comments allow you to bypass the formatting rules for your own components. Try it for yourself, turn it on/off and see what you like!

Build the new component

Run:

1php artisan twill:build

This will start by installing Twill's NPM dependencies in vendor/area17/twill/node_modules. Twill will check if the node_modules folder exist and if needed will run npm install. If you want to force npm install you can use:

1php artisan twill:build --install

This is only for Twill's own dependencies, you don't need to modify what is being installed in there. For your own dependencies in your custom components, you can run npm install directly into your project.

Use the new component

When the build is done, you can use the new component in your forms:

File:

resources/views/admin/pages/form.blade.php

1@extends('twill::layouts.form')
2 
3@section('contentFields')
4<a17-hello-world name="World"></a17-hello-world>
5@stop

In your markup, all custom components must have the a17- prefix. In my personal opinion, it's also best to use an additional prefix for your components, such as a17-custom-. This will avoid all possible conflicts with Twill's built-in components, which also share the a17- prefix.

The component in action:

01-hello-world

Introducing twill:dev

When developing components, you can take advantage of twill:dev for fast rebuilds, hot reloading and auto refresh in the browser:

1php artisan twill:dev

This will watch for changes in your resources/assets/js directory and rebuild accordingly.

Note for Sail/Docker users you may get the following error when running twill:dev:

1Error: $SHELL environment variable is not set.

In this case, you can prefix the twill:dev command like this:

1export SHELL=/usr/bin/bash && php artisan twill:dev

Twill dev mode

After running twill:dev, you need to inform Twill that you are in development mode by setting the following variables in your .env file:

1TWILL_DEV_MODE=true
2TWILL_DEV_MODE_URL="http://localhost:8080"

TWILL_DEV_MODE_URL is used by Twill to access the development assets through the hot reloading server. You don't need to access this address in your browser, simply access your development site as before (e.g. http://my-app.test)

For Valet users the above configuration should work out of the box.

For Homestead/Vagrant users if you are running twill:dev inside of the VM, make sure to set TWILL_DEV_MODE_URL to your actual development URL (e.g. TWILL_DEV_MODE_URL="http://my-app.test:8080")

For Sail/Docker users if you are running twill:dev inside of the container, make sure to expose the 8080 port in your docker-compose.yml or Dockerfile configuration.

Part 2 - Custom component practical example

The HelloWorld component is obviously not very useful. Custom components can be used to add interactivity to your Twill admin forms. Here's a more complete example of a CustomTabs component to organize your form fields in tabs:

02-custom-tabs

This reuses a bit of CSS from other parts of Twill (ie. .box__filter).

Vue.js component

File:

resources/assets/js/components/CustomTabs.vue

1 
2<template>
3 <!-- eslint-disable -->
4 <div class="custom-tabs">
5 <ul class="box__filter">
6 <li v-for="tab of tabs">
7 <a
8 href="#"
9 :class="{ 's--on': activeTab === tab.name }"
10 @click.prevent="activateTab(tab.name)"
11 >
12 {{ tab.label }}
13 </a>
14 </li>
15 </ul>
16 <slot></slot>
17 </div>
18</template>
19 
20<script>
21 /* eslint-disable */
22 
23 export default {
24 props: {
25 tabs: { type: Array },
26 },
27 
28 data () {
29 return {
30 activeTab: null
31 }
32 },
33 
34 mounted () {
35 if (this.tabs.length > 0) {
36 this.activateTab(this.tabs[0].name)
37 }
38 },
39 
40 methods: {
41 activateTab (name) {
42 this.$el.querySelectorAll('.custom-tab').forEach(tab => {
43 tab.classList.remove('is-active')
44 });
45 
46 const newActiveTab = this.$el.querySelector(`.custom-tab--${name}`)
47 
48 if (newActiveTab) {
49 newActiveTab.classList.add('is-active')
50 this.activeTab = name
51 }
52 }
53 },
54 }
55</script>
56 
57<style scoped>
58 .custom-tabs {
59 margin-top: 7px;
60 }
61 
62 .custom-tab {
63 display: none;
64 }
65 
66 .custom-tab.is-active {
67 display: initial;
68 }
69</style>

Usage in a form

File:

resources/views/admin/pages/form.blade.php

1@extends('twill::layouts.form')
2 
3@section('contentFields')
4 <a17-custom-tabs :tabs="[
5 { name: 'header', label: 'Header' },
6 { name: 'body', label: 'Body' },
7 { name: 'footer', label: 'Footer' },
8 ]">
9 <div class="custom-tab custom-tab--header">
10 <x-twill::input
11 name="header_title"
12 label="Header title"
13 :translated="true"
14 />
15 
16 <x-twill::input
17 name="header_subtitle"
18 label="Header subtitle"
19 :translated="true"
20 />
21 </div>
22 
23 <div class="custom-tab custom-tab--body">
24 <p>** Add body form fields here **</p>
25 </div>
26 
27 <div class="custom-tab custom-tab--footer">
28 <p>** Add footer form fields here **</p>
29 </div>
30 </a17-custom-tabs>
31@stop

Part 3 - Custom form field example

Form fields are at the core of the content management experience in any CMS. In Twill, form fields are usually made of 2 parts: a Vue.js component that hooks into Twill's Vuex store, and a Blade view that acts as an interface to this component.

Here's a complete example of a custom_number form field, based on the vue-numeric package:

03-1-custom-number

Install the dependency

1npm install vue-numeric --save

Vue.js component

File:

resources/assets/js/components/CustomNumber.vue

1 
2<template>
3 <!-- eslint-disable -->
4 <a17-inputframe
5 :error="error"
6 :note="note"
7 :label="label"
8 :name="name"
9 :required="required"
10 >
11 <div class="form__field">
12 <vue-numeric
13 :precision="precision"
14 :currency="currency"
15 :decimal-separator="decimalSeparator"
16 :thousand-separator="thousandSeparator"
17 :name="name"
18 v-model="value"
19 @blur="onBlur"
20 ></vue-numeric>
21 </div>
22 </a17-inputframe>
23</template>
24 
25<script>
26 /* eslint-disable */
27 
28 import InputMixin from '@/mixins/input'
29 import FormStoreMixin from '@/mixins/formStore'
30 import InputframeMixin from '@/mixins/inputFrame'
31 
32 import VueNumeric from 'vue-numeric'
33 
34 export default {
35 mixins: [InputMixin, InputframeMixin, FormStoreMixin],
36 
37 components: { VueNumeric },
38 
39 props: {
40 name: {
41 type: String,
42 required: true,
43 },
44 initialValue: {
45 type: Number,
46 default: 0,
47 },
48 precision: {
49 type: Number,
50 default: 2,
51 },
52 currency: {
53 type: String,
54 default: '$',
55 },
56 decimalSeparator: {
57 type: String,
58 default: '.',
59 },
60 thousandSeparator: {
61 type: String,
62 default: '',
63 },
64 },
65 
66 data () {
67 return {
68 value: this.initialValue,
69 }
70 },
71 
72 methods: {
73 updateFromStore (newValue) {
74 if (typeof newValue === 'undefined') newValue = ''
75 
76 if (this.value !== newValue) {
77 this.value = newValue
78 }
79 },
80 updateValue (newValue) {
81 if (this.value !== newValue) {
82 this.value = newValue
83 
84 this.saveIntoStore()
85 }
86 },
87 onBlur (event) {
88 const newValue = event.target.value
89 this.updateValue(newValue)
90 },
91 },
92 }
93</script>
94 
95<style lang="scss" scoped>
96 .form__field {
97 display: flex;
98 align-items: center;
99 padding: 0 15px;
100 overflow: visible;
101 
102 input {
103 @include resetfield;
104 width: 100%;
105 height: 43px;
106 line-height: 43px;
107 color: inherit;
108 padding: 0;
109 }
110 }
111</style>

Blade view

File:

resources/views/admin/partials/form/_custom_number.blade.php

1@php
2 $precision = $precision ?? '2';
3 $currency = $currency ?? '$';
4 $decimalSeparator = $decimalSeparator ?? '.';
5 $thousandSeparator = $thousandSeparator ?? '';
6@endphp
7 
8<a17-custom-number
9 label="{{ $label }}"
10 :precision="{{ $precision }}"
11 currency="{{ $currency }}"
12 decimal-separator="{{ $decimalSeparator }}"
13 thousand-separator="{{ $thousandSeparator }}"
14 @include('twill::partials.form.utils._field_name')
15 in-store="value"
16></a17-custom-number>
17 
18@unless($renderForBlocks || $renderForModal || (!isset($item->$name) && null == $formFieldsValue = getFormFieldsValue($form_fields, $name)))
19@push('vuexStore')
20 window['{{ config('twill.js_namespace') }}'].STORE.form.fields.push({
21 name: '{{ $name }}',
22 value: {!! json_encode($item->$name ?? $formFieldsValue) !!}
23 })
24@endpush
25@endunless

Usage in a form

File:

resources/views/admin/pages/form.blade.php

1@extends('twill::layouts.form')
2 
3@section('contentFields')
4 @formField('custom_number', [
5 'name' => 'magic_number',
6 'label' => 'Magic Number',
7 'thousandSeparator' => ',',
8 ])
9@stop

Digging deeper

This form field is functional but very limited, on purpose. For example, it is not translatable. At this time, the best way to explore what's possible with form fields is to dig into Twill's built-in form components.

Here are a few good places to start exploring:

  • HiddenField.vue Possibly the simplest form field in Twill.
  • DatePicker.vue A good example of form field that integrates with an external JS library.
  • TextField.vue A complex form field with translations and type variations.

Part 4 - Custom block example

The first thing to say about custom Vue.js blocks is that you probably don't need them! Since Twill 2.0, custom blocks can be created entirely with Blade views. The twill:make:block command can scaffold a Blade block for you:

1php artisan twill:make:block banner

This will create the following file in your project:

File:

resources/views/admin/blocks/banner.blade.php

1@twillBlockTitle('Banner')
2@twillBlockIcon('text')
3@twillBlockGroup('app')
4 
5<x-twill::input
6 name="title"
7 label="Title"
8/>
9 
10// ...

You can add all of Twill's built-in form fields in your blocks, as well as any custom components and form fields that you create. This is the reason why creating custom Vue.js blocks may not be necessary.

Here's an example of our custom_number field inside of a custom Blade block:

03-2-price-block

File:

resources/views/admin/blocks/price.blade.php

1@twillBlockTitle('Price')
2@twillBlockIcon('text')
3@twillBlockGroup('app')
4 
5@formField('custom_number', [
6 'name' => 'magic_number',
7 'label' => 'Magic Number',
8 'thousandSeparator' => ',',
9])

Nevertheless, if you wish to explore custom Vue.js blocks, you can use the twill:blocks command to get going.

Prepare your custom block

Add the @twillBlockCompiled directive to your Blade view:

File:

resources/views/admin/blocks/banner.blade.php

1@twillBlockTitle('Banner')
2@twillBlockIcon('text')
3@twillBlockGroup('app')
4@twillBlockCompiled('true')
5 
6// ...

Then run :

1php artisan twill:blocks

This will create the following file in your project:

File:

resources/assets/js/blocks/BlockBanner.vue

1 
2<template>
3 <!-- eslint-disable -->
4 <div class="block__body">
5 <a17-locale type="a17-textfield"
6 :attributes="{ label: 'Title', name: fieldName('title'), type: 'text', inStore: 'value' }"></a17-locale>
7 </div>
8</template>
9 
10<script>
11 import BlockMixin from '@/mixins/block'
12 
13 export default {
14 mixins: [BlockMixin]
15 }
16</script>

This file is effectively your Blade template, rendered into a Vue.js component shell. How cool is that?

Customize and build

From this point, you can customize the new Vue.js component in any way you want. Keep in mind that your custom block is limited to the following area in the Block Editor (shown in blue):

04-custom-block

When you are done, run:

1php artisan twill:build

Then the custom block is usable in the block editor:

File:

resources/views/admin/pages/form.blade.php

1@extends('twill::layouts.form')
2 
3@section('contentFields')
4 // ...
5 <x-twill::block-editor :blocks="['banner']"/>
6@stop