Variable Insertion with Alpine.js and Mustache.ex

August 10, 2021

Tags: Alpinejs, Javascript, Elixir

There are multiple scenarios in which a user might want to insert a variable into a message that will later fill in that variable with a value. Some examples include drafting an email to many recipients and including their name in the greeting, helping a customer via a chat application, sending text messages to different recipients that includes info about their city, etc.

We need to allow a user to select one or more field names while composing the message. Then, once the message is sent to a particular individual, the application interpolates the message with the values from the database associated with the selected fields. If it’s a text message, we also want to keep track of the character count of the composed message and notify the user if it is longer than 320 characters.

This is our goal:

Variable Selector

Message

We can accomplish this by incorporating Alpine.js, a JavaScript framework that integrates smoothly into Phoenix LiveView.

<div x-data="Components.variableSelector()">
  <div class="w-72">
    <textarea class="w-72 p-2" x-ref="textarea" x-init="updateLength()" x-on:keyup="updateLength()"></textarea>

    <div class="text-gray-600 float-right">
      <span x-text="length" x-bind:class="overLimit ? 'text-red-400 font-bold' : ''"></span>
      <span x-bind:class="overLimit ? 'text-red-400' : ''"> / </span>
      <span x-bind:class="overLimit ? 'text-red-400' : ''">320</span>
    </div>
  </div>

  <div class="flex flex-wrap justify-start bg-gray-300 p-4 pb-0 rounded-md mb-4 w-72">
    <%= for variable <- @variables do %>
      <span class="cursor-pointer mb-4 mr-2 text-white body-m rounded-full py-1 px-3 bg-green-800 hover:bg-green-600" x-on:click="insertVariable('<%= variable.insert %>')">
        <%= variable.name %>
      </span>
    <% end %>
  </div>
</div>

First we mount an Alpine component using x-data. The component assigned to the variableSelector() function holds the logic for performing the insertion with the curly braces and for counting the characters. We will look at that in detail momentarily. We give the textarea an x-ref of textarea so that we can reference the element directly using the $refs magic property. We use x-init and x-on:keyup to count the number of characters when Alpine first initializes the element (e.g. for an edit page that might already have content) and when a keyup action occurs (to update as a user types). To display the count we give a span an attribute of x-text to set its content to length which is the value of the character count. We can then dynamically set classes on the span elements with x-bind and change how it looks if it is over the character limit. Then for the variables displayed as pills, we add an x-on:click which will run the insertVariable() function which is in the Component.variableSelector().

const variableSelector = () => {
 return {
   insertVariable(name) {
     const DELIMITER_LENGTH = 4
     const textarea = this.$refs.textarea
     const start = textarea.selectionStart
     const end = textarea.selectionEnd
     const currentText = textarea.value
 
     textarea.value = `${currentText.substring(
       0,
       start,
     )}{{${name}}}${currentText.substring(end)}`
     textarea.focus()
     const newEnd = textarea.selectionStart + name.length + DELIMITER_LENGTH
     textarea.setSelectionRange(newEnd, newEnd)
     this.updateLength()
   },
   length: 0,
   overLimit: false,
   updateLength() {
     const VARIABLE_LENGTH = 10
     const content = this.$refs.textarea.value
     let count = 0
     const strippedContent = content.replace(/{{([^}]*)}}/g, () => {
       count += 1
       return ""
     })
 
     this.length = strippedContent.length + count * VARIABLE_LENGTH
     this.overLimit = this.length > 320
   },
 }
}

export { variableSelector }

The variableSelector() function mounted as the Alpine component returns the insertVariable() function, the length which is the character count for the textarea, the boolean for overLimit, and the function updateLength(). When a variable is clicked by the user, the insertVariable() function is invoked with an argument of the text that will be inserted. The value of the textarea can be referenced with this.$ref.textarea because we gave it the x-ref of textarea. Using zero-indexing, we calculate where the variable is to be inserted with selectionStart and selectionEnd and we grab the text that currently exists with textarea.value. We can then use the substring() function to insert {{${name}}} where the cursor was when the user clicked on the variable, and the textarea.value is updated to include the newly inserted characters in the textarea. We then focus on the textarea to make it easy for the user to continue typing their message and indicate that the cursor should go to the end of the text with setSelectionRange() which uses a new end value for the text by including the length of the variable name plus 4 for the curly braces.

The last action of the insertVariable() function is to call updateLength(). This function uses regex to remove the double curly braces and text inside so that we can dictate how many characters are counted for a variable. Check out this StackOverflow for an explanation of the regex. We need to manually calculate the length of the text because the variable inserted and final value that will be inserted will rarely be the same length and we instead want to give a particular count. We chose 10 characters (this was a fairly arbitrary decision to pick 10 for our use case) for each variable and therefore add the number of inserted variables multiplied by 10 to get the total character count and set this to this.length. Since length is the x-text of the span for the count, as the function updates, so does the count that is displayed in the span.

We also have an attribute of overLimit which starts as false, and once this.length is greater than 320 it is set to true which adds classes to the spans to change the text to red.

To get access to the variableSelector() function we need to make sure to import it into our app.ts file and we make it accessible as Components.variableSelector() by setting it on our window.

import { variableSelector } from "./components/variableSelectorInput"
 
// Make Alpine Components available
const ComponentsWindow = window as any
ComponentsWindow.Components = {
  variableSelector: variableSelector,
}

Once we have a working variable insertion, we need to be able to replace the variables with real values. To do this we can use the Mustache.ex package which allows for rendering strings with minimal templating: only using the double curly braces.

Once it is added as a dependency we can create a Mustache.ex module:

defmodule MyApp.Mustache do
 @moduledoc """
 Renders Mustache templates.
 
 Uses `Mustache` under the hood.
 
 `Mustache` works by expanding tags in a template using values provided in
 a map.
 """
 
 @doc """
 Returns rendered Mustache template with given `data`.
 
 Returns `nil` or empty string if given, else tries to render valid string template.
 
 ## Examples
 
     iex> render_string(nil)
     nil
 
     iex> render_string("")
     ""
 
     iex> render_string("Hello {{hello}}!", %{hello: "world"})
     "Hello world!"
 
 """
 @spec render_string(String.t() | nil, map) :: String.t() | nil
 def render_string(nil, _data), do: nil
 def render_string("", _data), do: ""
 
 def render_string(value, data) do
   Mustache.render(value, data)
 end
end

Which handles nil, empty string, and valid strings when render_string/2 is invoked.

In the context module where we handle the creation of the message (let’s call it Messages), we can add a function that will use this render_string/2 function with the variables and values that we pass it.

@spec render_message(Message.t(), User.t()) :: String.t()
 def render_message(message, user) do
   user_data =
     Map.take(user, [
       :city,
       :email,
       :first_name
     ])
 
   Mustache.render_string(
     message.content,
     %{user: user_data}
   )
 end

We pass the render_message/2 a struct that has a content string that needs to be interpolated such as: "Hello {{user.first_name}}." We also pass as a second argument, a User struct and using Map.take/2 safely pull out only the keys and values allowed for interpolation. The message and the map of the user data are then passed to the Mustache.render_string/2. Since the user_data map is nested under a user key, Mustache will look for {{user.email}} when converting the variables to values.

Finally, wherever we want to take the message and display it with real values instead of templated variables we can use

<%= Messages.render_message(message, user) %>

Where message is "Hello, {{user.first_name}!" and user is:

%User{first_name: "John"}

The result is: "Hello, John!".

For a working example of the variable insertion and counter, checkout this CodePen.

Meks McClure

Junior Developer

Meks is a former biologist and philosopher turned developer. They enjoy a plethera of activities including yoga practice, ridiculously long walks with their dog, and reading sci-fi and high fantasy novels. they/them

let's talk.

Together we can make something great.

contact us about new work

Contact

225-407-4520

hello@newaperio.com

Social Media