Currently Available: Need a skilled Software Developer for your next project?
Categories
Laravel

Building Custom UIs with AJAX in Statamic Addons

Creating custom UI components in Statamic addons with AJAX functionality is poorly documented, making it frustrating to implement. This guide shows you exactly how to build Vue components, handle AJAX requests, and integrate them seamlessly into the Statamic Control Panel.

The Basic Architecture

Statamic addons can include Vue components that integrate with the Control Panel. Here's what you need:

  1. Vue components in resources/js/components/
  2. A build system (Vite) to compile assets
  3. Routes to handle AJAX requests
  4. Controllers to process those requests
  5. A way to register and use your components

Setting Up Your Build System

First, create vite.config.js in your addon root:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue2';

export default defineConfig({
    plugins: [
        laravel({
            hotFile: '../../../public/vendor/internal-linker/hot',
            buildDirectory: 'vendor/internal-linker',
            input: [
                'resources/js/cp.js',
            ],
            refresh: true,
        }),
        vue(),
    ],
});

Add build scripts to your package.json:

{
    "scripts": {
        "dev": "vite",
        "build": "vite build"
    },
    "devDependencies": {
        "@vitejs/plugin-vue2": "^2.3.1",
        "vite": "^5.0",
        "laravel-vite-plugin": "^1.0"
    }
}

Creating Your First Vue Component

Here's a simple component that makes AJAX requests:

<template>
    <div class="card p-4">
        <h2 class="mb-4">My Custom Component</h2>

        <div v-if="loading" class="loading loading-basic active">
            <span>Loading...</span>
        </div>

        <div v-else-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
            {{ error }}
        </div>

        <div v-else>
            <button @click="fetchData" class="btn-primary">
                Fetch Data
            </button>

            <div v-if="data" class="mt-4">
                <pre>{{ data }}</pre>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    props: {
        apiUrl: String,
        entryId: String
    },

    data() {
        return {
            loading: false,
            error: null,
            data: null
        };
    },

    methods: {
        async fetchData() {
            this.loading = true;
            this.error = null;

            try {
                const response = await this.$axios.get(this.apiUrl, {
                    params: { entry_id: this.entryId }
                });

                this.data = response.data;
            } catch (error) {
                // Handle session expiration
                if (error.response?.status === 419) {
                    this.error = 'Your session has expired. Please refresh the page.';
                } else {
                    this.error = error.response?.data?.message || 'An error occurred';
                }
            } finally {
                this.loading = false;
            }
        }
    }
};
</script>

Registering Your Components

Create resources/js/cp.js to register your components:

import MyComponent from './components/MyComponent.vue';
import AnotherComponent from './components/AnotherComponent.vue';

// Register when Statamic boots
Statamic.booting(() => {
    // Register as a fieldtype
    Statamic.component('my-component-fieldtype', MyComponent);

    // Register as a general component
    Statamic.component('another-component', AnotherComponent);
});

Creating Routes for AJAX Endpoints

In your addon's routes/cp.php:

use Illuminate\Support\Facades\Route;

Route::prefix('internal-linker')->group(function () {
    // GET endpoint for fetching data
    Route::get('/data', [MyController::class, 'getData'])
        ->name('internal-linker.data');

    // POST endpoint for saving data
    Route::post('/save', [MyController::class, 'saveData'])
        ->name('internal-linker.save');

    // More complex endpoint with validation
    Route::post('/process', [MyController::class, 'process'])
        ->name('internal-linker.process');
});

Building AJAX Controllers

Create controllers that handle your AJAX requests:

<?php

namespace YourVendor\YourAddon\Http\Controllers\CP;

use Statamic\Http\Controllers\CP\CpController;
use Illuminate\Http\Request;

class MyController extends CpController
{
    public function getData(Request $request)
    {
        $entryId = $request->input('entry_id');

        // Your business logic here
        $data = $this->myService->processEntry($entryId);

        return response()->json([
            'success' => true,
            'data' => $data
        ]);
    }

    public function saveData(Request $request)
    {
        $request->validate([
            'entry_id' => 'required|string',
            'content' => 'required|string',
        ]);

        try {
            $result = $this->myService->save(
                $request->input('entry_id'),
                $request->input('content')
            );

            return response()->json([
                'success' => true,
                'message' => 'Data saved successfully',
                'data' => $result
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'success' => false,
                'message' => 'Failed to save data'
            ], 500);
        }
    }
}

Advanced Patterns

Making POST Requests with CSRF Protection

async saveData() {
    try {
        const response = await this.$axios.post('/cp/internal-linker/save', {
            entry_id: this.entryId,
            content: this.content
        });

        // Show success message
        this.$toast.success(response.data.message);

        // Emit event to parent
        this.$emit('saved', response.data);
    } catch (error) {
        if (error.response?.status === 419) {
            this.$toast.error('Session expired. Please refresh the page.');
        } else {
            this.$toast.error('Failed to save data');
        }
    }
}

Creating a Custom CP Page

In your service provider:

use Statamic\Facades\CP\Nav;

protected function bootAddon()
{
    Nav::extend(function ($nav) {
        $nav->content('My Addon')
            ->section('Tools')
            ->route('internal-linker.stats')
            ->icon('charts');
    });
}

Create a Blade view for your CP page:

@extends('statamic::layout')
@section('title', 'My Custom Page')

@section('content')
    <div class="flex items-center justify-between mb-6">
        <h1>My Custom Page</h1>
    </div>

    <my-component
        :api-url="'{{ cp_route('internal-linker.data') }}'"
        :entry-id="'{{ $entry_id }}'"
    ></my-component>
@stop

@push('head')
    @vite('resources/js/cp.js', 'vendor/internal-linker')
@endpush

Creating a Modal Component

<template>
    <modal name="my-modal" :pivot-y="0.1" width="80%" height="auto">
        <div class="modal-content p-4">
            <header class="text-lg font-semibold mb-4">
                {{ title }}
            </header>

            <div class="modal-body">
                <!-- Your content here -->
            </div>

            <footer class="flex justify-end gap-2 mt-6">
                <button @click="$modal.hide('my-modal')" class="btn">
                    Cancel
                </button>
                <button @click="save" class="btn-primary" :disabled="loading">
                    Save Changes
                </button>
            </footer>
        </div>
    </modal>
</template>

<script>
export default {
    methods: {
        show() {
            this.$modal.show('my-modal');
        },

        hide() {
            this.$modal.hide('my-modal');
        },

        async save() {
            // Your save logic
            this.hide();
        }
    }
};
</script>

Handling Real-time Updates

For components that need to refresh data:

mounted() {
    // Initial load
    this.loadData();

    // Refresh every 30 seconds
    this.refreshInterval = setInterval(() => {
        this.loadData();
    }, 30000);
},

beforeDestroy() {
    if (this.refreshInterval) {
        clearInterval(this.refreshInterval);
    }
},

methods: {
    async loadData() {
        // Only refresh if not currently loading
        if (!this.loading) {
            await this.fetchData();
        }
    }
}

Best Practices

1. Always Handle Loading States

Users need visual feedback. Always implement loading indicators:

data() {
    return {
        loading: false,
        saving: false,
        deleting: false
    };
}

2. Consistent Error Handling

Create a mixin for consistent error handling:

// mixins/ErrorHandler.js
export default {
    methods: {
        handleError(error) {
            if (error.response?.status === 419) {
                this.$toast.error('Session expired. Please refresh the page.');
            } else if (error.response?.status === 422) {
                const errors = error.response.data.errors;
                Object.keys(errors).forEach(field => {
                    this.$toast.error(errors[field][0]);
                });
            } else {
                this.$toast.error(error.response?.data?.message || 'An error occurred');
            }
        }
    }
};

3. Use Statamic's Built-in Components

Leverage Statamic's components for consistency:

<template>
    <div>
        <!-- Data table -->
        <data-list
            :rows="items"
            :columns="columns"
            :sort="false"
            :search="true"
        />

        <!-- Loading state -->
        <loading-graphic v-if="loading" />

        <!-- Empty state -->
        <div v-if="!loading && items.length === 0" class="card p-6 text-center">
            <p class="text-gray-500">No items found</p>
        </div>
    </div>
</template>

4. Pass Routes as Props

Don't hardcode URLs. Pass them from Blade:

<my-component
    :routes='{
        "fetch": "{{ cp_route('internal-linker.data') }}",
        "save": "{{ cp_route('internal-linker.save') }}",
        "delete": "{{ cp_route('internal-linker.delete') }}"
    }'
/>

Common Gotchas

  1. CSRF Token Issues: Statamic's axios instance handles this automatically, but make sure you're using this.$axios not importing axios directly.

  2. Asset Loading: Always use the @vite directive with your package name to ensure assets load correctly.

  3. Route Names: Prefix your routes to avoid conflicts with other addons.

  4. State Management: For complex UIs, consider using Vuex (already included with Statamic).

What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Subscribe to my newsletter

Get new posts when I publish them.

I respect your privacy. Unsubscribe at any time.

Leave a Reply

Your email address will not be published. Required fields are marked *