Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save madalinignisca/46ce1a2a77d90e1f3fe8297d7d0098a1 to your computer and use it in GitHub Desktop.
Save madalinignisca/46ce1a2a77d90e1f3fe8297d7d0098a1 to your computer and use it in GitHub Desktop.
Twill Vue.js Components Workflow

Vue.js Workflow - Creating custom components, form fields and blocks in Twill

Objectives:

  • Get an overview of Twill's Artisan commands for development — Part 1
  • Create a custom Vue.js component — Part 2
  • Create a custom Twill form field — Part 3
  • Create a custom Twill block — Part 4

Requirements:

Versions used at the time of writing:

Version
PHP 8.0
Laravel 8.x
Twill 2.5.x

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


<template>
    <!-- eslint-disable -->
    <div>
        Hello {{ name }}
    </div>
</template>

<script>
/* eslint-disable */

export default {
    props: ['name']
}
</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:

php artisan twill:build

This will start by installing Twill's NPM dependencies in vendor/area17/twill/node_modules. This is only required for the first run, you can skip the install step for your next builds with the --noInstall option:

php artisan twill:build --noInstall

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


@extends('twill::layouts.form')

@section('contentFields')
    <a17-hello-world name="World"></a17-hello-world>
@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:

php artisan twill:dev --noInstall

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:

Error: $SHELL environment variable is not set.

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

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

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:

TWILL_DEV_MODE=true
TWILL_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


<template>
    <!-- eslint-disable -->
    <div class="custom-tabs">
        <ul class="box__filter">
            <li v-for="tab of tabs">
                <a
                    href="#"
                    :class="{ 's--on': activeTab === tab.name }"
                    @click.prevent="activateTab(tab.name)"
                >
                    {{ tab.label }}
                </a>
            </li>
        </ul>
        <slot></slot>
    </div>
</template>

<script>
/* eslint-disable */

export default {
    props: {
        tabs: { type: Array },
    },

    data() {
        return {
            activeTab: null
        }
    },

    mounted() {
        if (this.tabs.length > 0) {
            this.activateTab(this.tabs[0].name)
        }
    },

    methods: {
        activateTab(name) {
            this.$el.querySelectorAll('.custom-tab').forEach(tab => {
                tab.classList.remove('is-active')
            });

            const newActiveTab = this.$el.querySelector(`.custom-tab--${name}`)

            if (newActiveTab) {
                newActiveTab.classList.add('is-active')
                this.activeTab = name
            }
        }
    },
}
</script>

<style scoped>
.custom-tabs {
    margin-top: 7px;
}

.custom-tab {
    display: none;
}

.custom-tab.is-active {
    display: initial;
}
</style>

Usage in a form

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


@extends('twill::layouts.form')

@section('contentFields')
    <a17-custom-tabs :tabs="[
        { name: 'header', label: 'Header' },
        { name: 'body', label: 'Body' },
        { name: 'footer', label: 'Footer' },
    ]">
        <div class="custom-tab custom-tab--header">
            @formField('input', [
                'name' => 'header_title',
                'label' => 'Header Title',
                'translated' => true,
            ])

            @formField('input', [
                'name' => 'header_subtitle',
                'label' => 'Header Subtitle',
                'translated' => true,
            ])
        </div>

        <div class="custom-tab custom-tab--body">
            <p>** Add body form fields here **</p>
        </div>

        <div class="custom-tab custom-tab--footer">
            <p>** Add footer form fields here **</p>
        </div>
    </a17-custom-tabs>
@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

npm install vue-numeric --save

Vue.js component

// file: resources/assets/js/components/CustomNumber.vue


<template>
    <!-- eslint-disable -->
    <a17-inputframe
        :error="error"
        :note="note"
        :label="label"
        :name="name"
        :required="required"
    >
        <div class="form__field">
            <vue-numeric
                :precision="precision"
                :currency="currency"
                :decimal-separator="decimalSeparator"
                :thousand-separator="thousandSeparator"
                :name="name"
                v-model="value"
                @blur="onBlur"
            ></vue-numeric>
        </div>
    </a17-inputframe>
</template>

<script>
/* eslint-disable */

import InputMixin from '@/mixins/input'
import FormStoreMixin from '@/mixins/formStore'
import InputframeMixin from '@/mixins/inputFrame'

import VueNumeric from 'vue-numeric'

export default {
    mixins: [InputMixin, InputframeMixin, FormStoreMixin],

    components: { VueNumeric },

    props: {
        name: {
            type: String,
            required: true,
        },
        initialValue: {
            type: Number,
            default: 0,
        },
        precision: {
            type: Number,
            default: 2,
        },
        currency: {
            type: String,
            default: '$',
        },
        decimalSeparator: {
            type: String,
            default: '.',
        },
        thousandSeparator: {
            type: String,
            default: '',
        },
    },

    data() {
        return {
            value: this.initialValue,
        }
    },

    methods: {
        updateFromStore(newValue) {
            if (typeof newValue === 'undefined') newValue = ''

            if (this.value !== newValue) {
                this.value = newValue
            }
        },
        updateValue(newValue) {
            if (this.value !== newValue) {
                this.value = newValue

                this.saveIntoStore()
            }
        },
        onBlur(event) {
            const newValue = event.target.value
            this.updateValue(newValue)
        },
    },
}
</script>

<style lang="scss" scoped>
.form__field {
    display: flex;
    align-items: center;
    padding: 0 15px;
    overflow: visible;

    input {
        @include resetfield;
        width: 100%;
        height: 43px;
        line-height: 43px;
        color: inherit;
        padding: 0;
    }
}
</style>

Blade view

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


@php
    $precision = $precision ?? '2';
    $currency = $currency ?? '$';
    $decimalSeparator = $decimalSeparator ?? '.';
    $thousandSeparator = $thousandSeparator ?? '';
@endphp

<a17-custom-number
    label="{{ $label }}"
    :precision="{{ $precision }}"
    currency="{{ $currency }}"
    decimal-separator="{{ $decimalSeparator }}"
    thousand-separator="{{ $thousandSeparator }}"
    @include('twill::partials.form.utils._field_name')
    in-store="value"
></a17-custom-number>

@unless($renderForBlocks || $renderForModal || (!isset($item->$name) && null == $formFieldsValue = getFormFieldsValue($form_fields, $name)))
@push('vuexStore')
    window['{{ config('twill.js_namespace') }}'].STORE.form.fields.push({
        name: '{{ $name }}',
        value: {!! json_encode($item->$name ?? $formFieldsValue) !!}
    })
@endpush
@endunless

Usage in a form

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


@extends('twill::layouts.form')

@section('contentFields')
    @formField('custom_number', [
        'name' => 'magic_number',
        'label' => 'Magic Number',
        'thousandSeparator' => ',',
    ])
@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:

php artisan twill:make:block banner

This will create the following file in your project:

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


@twillBlockTitle('Banner')
@twillBlockIcon('text')
@twillBlockGroup('app')

@formField('input', [
    'name' => 'title',
    'label' => 'Title',
])

// ...

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


@twillBlockTitle('Price')
@twillBlockIcon('text')
@twillBlockGroup('app')

@formField('custom_number', [
    'name' => 'magic_number',
    'label' => 'Magic Number',
    'thousandSeparator' => ',',
])

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


@twillBlockTitle('Banner')
@twillBlockIcon('text')
@twillBlockGroup('app')
@twillBlockCompiled('true')

// ...

Then run :

php artisan twill:blocks

This will create the following file in your project:

// file: resources/assets/js/blocks/BlockBanner.vue


<template>
  <!-- eslint-disable -->
  <div class="block__body">
    <a17-locale type="a17-textfield" :attributes="{ label: 'Title', name: fieldName('title'), type: 'text', inStore: 'value' }" ></a17-locale>
  </div>
</template>

<script>
import BlockMixin from '@/mixins/block'

export default {
  mixins: [BlockMixin]
}
</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:

php artisan twill:build --noInstall

Then the custom block is usable in the block editor:

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


@extends('twill::layouts.form')

@section('contentFields')
	// ...

    @formField('block_editor', [
        'blocks' => ['banner'],
    ])
@stop


Thanks for reading and have fun :)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment