Skip to content

platanus/activeadmin-alpinejs-examples

Repository files navigation

ActiveAdmin + AlpineJS

If you're tired of dealing with jQuery's messy file structure, AlpineJS proves itself to be a worthy alternative. In most cases there's no need for an external JS file, so all the necessary code for a function to work can be declared directly into your resource's ActiveAdmin file.

Installing AlpineJS

# When using Shakapacker, Webpack 5+ or other modern bundlers
yarn add alpine

# When using Webpacker
yarn add alpine@2

Then we need to add the following to a javascript file that runs when ActiveAdmin is active, usually the one with import '@activeadmin/activeadmin';

import '@activeadmin/activeadmin';

import Alpine from 'alpinejs';

window.Alpine = Alpine;
Alpine.start();

if you're using Alpine 2 (due to Webpacker), you need to use the following instead:

import '@activeadmin/activeadmin';

import 'alpinejs'

❕ All examples in this repository use AlpineJS 3 but they should work as-is in AlpineJS 2.

Basics

An AlpineJS component is an html element with an x-data attribute with all the variables we'll use.

<div x-data="{open: false}"></div>

To make that work in ActiveAdmin, we need to add the x-data attribute either to inputs or input.

f.inputs 'x-data':  CGI.escapeHTML("{...#{f.resource.attributes.to_json}}") do
  f.input :name
end

CGI.escapeHTML is necessary to avoid breaking printing something that'll escape the x-data attribute (usually because of a double quote)

f.resource.attributes gives us access to all the attributes the model has. Instead of using it you can declare values by hand, always remembering it needs to be a valid javascript object.

❗ Most examples assume the x-data attribute has been declared.

Once we have initialized the component with x-data we can start using AlpineJS's directives.

f.inputs 'x-data':  CGI.escapeHTML("{...#{f.resource.attributes.to_json}}") do
  f.input :name, input_html: { 'x-model': 'name' }
end

Format a field when writing

When to use: Telephone numbers, currency values, national id numbers.

To start, we need to add the formatter function to the same file where we initialized Alpine and expose it to the browser page.

window.Alpine = Alpine;
Alpine.start();

window.formatters = {
  // Formats a number to currency
  currency: new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP' }),
  // Removes everything that's not a number from a string
  numberCleaner(value) {
    return value.replaceAll(/\D/g, '');
  },
};

Then, inside our admin file we use x-on:input (or @input) to format the value every time we input a number into our input.

❕ For this specific example we need to clean the number (so that it's actually a number (1000) instead of a string (1.000), so we run numberCleaner before format.

f.input :amount, input_html: {
  'x-model': 'amount',
  'x-on:input': 'amount = formatters.currency.format(formatters.numberCleaner($event.target.value));'
}

If we reload the page, the amount will not be formatted since the formatter only runs on input. For that we need to edit the initial data in x-data.

f.inputs 'x-data': CGI.escapeHTML("{ amount: formatters.currency.format('#{f.resource.attributes['amount']}')}") do

  f.input :amount, input_html:
    'x-model': 'amount',
    'x-on:input': 'amount = formatters.currency.format(formatters.numberCleaner($event.target.value));
    '

In the case the model's attribute is an integer in the database, we can't save the formatted string. For that, we need to add a couple of things to have to inputs, the formatted one and the "real" one that is saved to the database.

In the model add:

class FormatFieldExample < ApplicationRecord
  attr_accessor :active_admin_amount
end

attr_accessor is necessary since ActiveAdmin doesn't allow values that don't exist to be shown as inputs.

Then in our admin file we use amount directly and then add a formatted active_admin_amount to x-data.

f.inputs 'x-data': CGI.escapeHTML("{
    amount: #{f.resource.attributes['amount']},
    active_admin_amount: formatters.currency.format('#{f.resource.attributes['amount']}')},
  }") do

For the visible amount, we replace it with active_admin_amount and update the x-on:input method so it also updates the amount in x-data.

  f.input :active_admin_amount, input_html:
    'x-model': 'active_admin_amount',
    'x-on:input': '
      active_admin_amount = formatters.currency.format(formatters.numberCleaner($event.target.value));
      amount = formatters.numberCleaner(active_admin_amount);
    '

Finally, we add a hidden input with the real amount so it gets saved in the database as a number.

f.input :amount, as: :hidden, input_html: {
  'x-bind:value': 'amount'
}

source code

Validate a Field

When to use: To prevent the form being able to be saved if a value is not valid, to display when a value needs to be filled or it has an invalid value.

As in the previous example, we need to add our validation function to the window variable so it's available in the ActiveAdmin page.

import { rutValidate } from 'rut-helpers';

window.validators = {
  // Formats a value to the standard RUT format.
  rut: rutValidate
};

In this example we want to change the input's class if the value is not valid, for that we need to use x-bind:class (or :class) so the class error gets dynamically added when validators.rut(rut) is false:

f.input :rut, input_html: {
  'x-model': 'rut',
  'x-bind:class': '{error: !validators.rut(rut)}'
}

If we also want to disable the submit button, we can edit the submit action to add the disabled attribute. x-bind:disabled (or :disabled) dynamically adds the attribute when the validator is false.

f.actions do
  f.action :submit, button_html: { 'x-bind:disabled': "!validators.rut(rut)" }
end

source code

Toggle a Field's Visibility

Being able to show or hide a field can be done using Alpine's x-show directive.

First we need a field with x-model so we can check its value afterwards

f.input :has_description, input_html: {
  'x-model': 'has_description'
}

And then we add the x-show directive to the field we want to show or hide depending on the value of the has_description field.

f.input :description, wrapper_html: {
  'x-show': 'has_description'
}

❗ We need to use wrapper_html instead of input_html to hide both the label and the input field.

source code

Select2 Fields

ActiveAdmin Addons transforms all select controls to use Select2, to make it easier to add large collections or tags. However, AlpineJS doesn't know what to do with Select2 elements (and the other way around).

To make select elements with x-model attributes work we need to install active-admin-alpine-fixes.

yarn add active-admin-alpine-fixes

Then, we have to make the fix available to the DOM.

import { select2 } from 'active-admin-alpine-fixes';

window.alpineFixes = { select2 };

Finally, we add the fix to our AlpineJS component by adding the x-init attribute so that it can run the fix as soon as the component runs.

f.inputs 'x-init': 'alpineFixes.select2.init', 'x-data':  CGI.escapeHTML("{...#{f.resource.attributes.to_json}}") do
  f.input :choices, input_html: { 'x-model': 'choices' }
end

source code

Has Many

ActiveAdmin allows us to add a nested form when a resource has a has_many. When you click "new resource" inside this nested form, ActiveAdmin uses jQuery to create the new fields and AlpineJS gets confused.

To make them work we need to install active-admin-alpine-fixes.

yarn add active-admin-alpine-fixes

Then, we have to make the fix available to the DOM.

import { hasMany } from 'active-admin-alpine-fixes';

window.alpineFixes = { hasMany };

In our component we need to add the fix to the x-init directive, and in our x-data we need to explicitly add the nested resource. Inside has_many, we need to declare the x-model using the available index number so that AlpineJS knows what field corresponds to what element in the children array.

f.inputs 'x-init': 'alpineFixes.hasMany.init',
          'x-data': CGI.escapeHTML("{
            ...#{f.resource.attributes.to_json},
            children: #{f.resource.children.to_json}
          }") do
  f.has_many :children, allow_destroy: true do |co, i|
    # has_many index starts with 1 while javascript's starts with 0 so we subtract one
    co.input :name, input_html: {
      'x-model': "children[#{i - 1}].name"
    }
  end
end

source code

Complex Forms (AlpineJS 3 Only - Alpine.data)

If our form is really complex or it has functionality that can very easily be reused, we can use Alpine.data in our javascript to declare an object that can be used in our form without having to expose variables with window. In other words, we can have a different JS file with everything we need and then we can import it and use it in our main JS file.

// activeadmin/complex_example.js

export default (attributes = {}) => {
  // We can replace the x-init directive with a function called 'init', it'll get automatically called when the component is mounted.
  function init() {
    // We need to pass the Alpine context (this) so it can find the element.
    select2.init.bind(this)();
  }

  const currencyFormat = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP' });

  function numberCleaner(value) {
    return value.replaceAll(/\D/g, '');
  }


  // We return an object that will be available inside our component
  return { ...attributes, init, currencyFormat, numberCleaner };
};
import complexExample from './activeadmin/complex_example';

Alpine.data('complexExample', complexExample);
Alpine.start();

Once we have done the above, we can use complexExample in our x-data, which receives the attributes we need to initialize the data.

form do |f|
  # Since since we haven't run complexExample, currencyFormat is not available yet so we can't use it
  # in x-data. You can add the formatter to the window variable separately, process the attributes inside
  # the complexExample, or, like in this case, use Ruby to get the same result.
  f.inputs 'x-data': "complexExample(#{CGI.escapeHTML("{
      ...#{f.resource.attributes.to_json},
      active_admin_amount: '#{number_to_currency(f.resource.attributes['amount'])}'
    }")})" do
    f.input :name

    f.input :active_admin_amount, input_html: {
      'x-model': 'active_admin_amount',
      # We can use currencyFormat and numberCleaner directly since they are available inside
      # the data object returned by the complexExample function.
      'x-on:input': '
        active_admin_amount = currencyFormat.format(numberCleaner($event.target.value));
        amount = numberCleaner(active_admin_amount);
      '
    }

    f.input :choices, input_html: { 'x-model': 'choices' }

    f.input :amount, as: :hidden, input_html: {
      'x-bind:value': 'amount'
    }

    f.actions do
      f.action :submit, button_html: { 'x-bind:disabled': "!rutValidate(rut)" }
    end
  end
end

source code

Running this repo

Assuming you've just cloned the repo, run this script to setup the project in your machine:

  ./bin/setup

It assumes you have a machine equipped with Ruby, Node.js, Docker and make.

The script will do the following among other things:

  • Install the dependencies
  • Create a docker container for your database
  • Prepare your database

After the app setup is done you can run it with

  bundle exec rails s