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:
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.