How to create Github-like references to your database records with a Liveview hook
As I am waiting for the release of Tekken 8, I am adding the last touches to my Tekken Teacher application. For those of you who don't know, Tekken is a Japanese 3D fighting game. This is the type of game that anybody can pick up and instantly have fun with. However when it comes to reaching higher levels of play, there's usually knowledge involved - Tekken Teacher aims to gather and present it in a digestible format, with traceability across the seasons.
Context
Without going into too much details about the game : each character has multiple strings, that constitutes a character's movelist. A string can have one or multiple moves related to it. Moves can be performed from various states (e.g: while running, while crouching, etc.) or stances (depending on the character you play). Finally, a move has various properties attached to it, that we call frame data.
Since Tekken is a complex game, I wanted a way to reference these various entities in a text
field to explain certain intricacies.
UI
When inserting a move, we have the possibility to create properties associated to it with the help of <.inputs_for {...} />
. We will focus on the notes
field, that is using a custom input type called input-detection
.
...
<.input
phx-target={@myself}
field={frame_data_form[:notes]}
type="input-detection"
label="Notes"
queryable={@queryable}
match_index={@match_index}
margin_left={@calculated_margin}
/>
...
It looks like a regular text input, but detects whenever a "#" is typed and displays a dropdown of states and/or stances from the database, that corresponds to your query.
How does it work ?
Let's take a closer look to the input-detection
type. This is the exact same HTML markup of the auto-generated def input/1
from Phoenix, with a little twist to it.
First we add the phx-hook="InputDetection"
, that we will discuss about a bit later. Then, underneath the text input we add the dropdown that will appear anytime we make a query.
Query
When we perform a query, we hit 2 separates tables with similar fields, states and stances :
As you can see, the query returns a list of maps with :name
and :notation
keys.
Now let's have a look at the HTML markup.
Markup
def input(%{type: "input-detection"} = assigns) do
...
<div class="relative">
<div
:if={!Enum.empty?(@queryable)}
id="z-dropdown"
class={[
"absolute top-0 max-w-32 max-h-64 bg-white shadow-lg",
"inline-block z-50 divide-y divide-solid rounded-md overflow-scroll"
]}
style={"margin-left: #{@margin_left}px;"}
>
<div
:for={item <- @queryable}
phx-click={
JS.dispatch("term_selected",
to: "##{@id}",
detail: %{term: item[:name], index: @match_index}
)
}
class={[
"p-2 cursor-pointer hover:bg-gray-200 focus:bg-gray-200 last:hover:rounded-b-md first:hover:rounded-t-md",
item[:notation] && "h-14",
!item[:notation] && "h-10"
]}
>
<%= item[:name] %>
<p :if={item[:notation]} class="text-sm text-gray-400"><%= item[:notation] %></p>
</div>
</div>
</div>
...
end
For the dropdown, we are iterating through the @queryable
assign and add a phx-click
attribute to them. Each item will now dispatch the event term_selected
with the input ID, the name of the selected item and its @match_index
that corresponds to the index of the "#" inside the field. (e.g : in the screenshot from the UI section, the match_index
is equal to 0)
Hook : keyup event
The hook relies on the following regex : /\B#[a-z\d_-]+/gi
and is using 3 different event listeners:
First, let's focus on the keyup
event, in which we extract the value
and the cursor position from the Event
object. If there's a match with the regex, we extract it with its associated index.
In case of multiple matches, we pick the closest index to the cursor, to make sure we replace the correct term.
If the cursor is placed on the term and the term is longer than one character, we send the fetch_queryable
event to the server, with the term (bar the "#" prefix) and its index. Otherwise we send reset_queryable
to make the dropdown disappear. Finally, the calculated_margin
event is sent, to make sure that the dropdown is expanded near the written term.
// InputDetection.js
const POUND_REGEX = /\B#[a-z\d_-]+/gi
this.el.addEventListener('keyup', function (e) {
const { value, selectionStart } = e.target
if (value.match(POUND_REGEX)) {
const matchesWithIndex = getIndexes(value, POUND_REGEX)
const closestMatch = findClosestNumber(matchesWithIndex.map(({ index }) => index), selectionStart)
const currentMatch = matchesWithIndex.find(({ index }) => index === closestMatch)
if (selectionStart > closestMatch && selectionStart <= closestMatch + currentMatch.length) {
const substring = value.substring(closestMatch, selectionStart)
substring.length > 1 && this.pushEventTo(_this.el, 'fetch_queryable', {
term: substring.slice(1), match_index: currentMatch.index
})
} else {
this.pushEventTo(_this.el, 'reset_queryable', null)
}
_this.pushEventTo(_this.el, 'calculated_margin', { margin: calculatePosition(selectionStart) })
}
})
Server : Handling events sent from "keyup"
# form_component.ex
...
def handle_event("fetch_queryable", %{"term" => term, "match_index" => match_index}, socket) do
queryable = Movelist.list_queryable_for(socket.assigns.character.id, term)
{:noreply, socket |> assign(:queryable, queryable) |> assign(:match_index, match_index)}
end
def handle_event("reset_queryable", _params, socket) do
{:noreply, socket |> assign(:queryable, []) |> assign(:match_index, nil)}
end
def handle_event("calculated_margin", %{"margin" => margin}, socket) do
{:noreply, assign(socket, :calculated_margin, margin)}
end
...
With that, we covered the back and forth between the client and server on a keyup
event. Time to take a look at how the term_selected
event is handled.
Hook : term_selected event
When this event is dispatched, both the term and its index are sent as additional information. It is then available under the detail
key of the Event
object.
Using this information, we are now able to autocomplete the rest of the term when clicking on an item.
Let's use the following screenshot to illustrate how it works :
Let's say we click on Starburst (Osserva!), the event will be dispatched with Starburst
as a term, and 8
as an index.
We rely once again on the regex to retrieve every possible match with their index. Then, use find() to look for the match that corresponds to the sent index. Finally, using the current value of the input, we replace the term by the clicked value and send it to the server via the update_notes
event.
this.el.addEventListener('z-dropdown:autocomplete', e => {
const value = e.target.value
const matchIndex = e.detail.index
const replacement = e.detail.term.replace(/\s/g, '-').toLocaleLowerCase()
const matchesWithIndex = getIndexes(value, POUND_REGEX)
const currentMatch = matchesWithIndex.find(({ index }) => index === matchIndex)
const term = value.substring(matchIndex, matchIndex + currentMatch.length).slice(1)
const property_index = _this.el.id.match(/\d/)[0]
const notes = value.replace(term, replacement)
_this.pushEventTo(_this.el, 'update_notes', { notes, property_index })
})
Line 8 is irrelevant unless you are dealing with a child form via <inputs_for />
. In our case, we are updating the frame_data
field from the properties
table that is used as a field for <inputs_for />
.
The autogenerate id of the field looks like this move_properties_0_frame_data_0_notes
. It allows us to retrieve the property index from the properties
list in the params.
Server : Handling events sent from "term_selected"
Pretty straightforward when it comes to update the data from the server side :
def handle_event("update_notes", %{"notes" => notes, "property_index" => index}, socket) do
target_property = get_in(socket.assigns.move_params, ["properties", index, "frame_data"])
params =
put_in(socket.assigns.move_params, ["properties", index, "frame_data"], %{target_property | "notes" => notes})
changeset =
socket.assigns.changeset
|> Moves.change_move(params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
One thing I didn't mention : this solution requires the storage of the params on the validate
event in order to access the entire params
map in event like this.
Voilà !
If you made it this far, thanks for reading. I had a lot of fun implementing this feature. I am pretty sure there is a lot of things that can be done to improve/simplify it, don't hesitate to sound off in the comment section !
I will now paste the Javascript helper functions used in the hook below for reference.
// The remaining event I didn't cover.
// It allows you to update the list depending on the cursor position after a click
this.el.addEventListener('click', function (e) {
const { value, selectionStart } = e.target
if (value.match(POUND_REGEX)) {
const matchesWithIndex = getIndexes(value, POUND_REGEX)
const closestMatchIndex = findClosestNumber(matchesWithIndex.map(({ index }) => index), selectionStart)
const currentMatch = matchesWithIndex.find(({ index }) => index === closestMatchIndex)
if (selectionStart > closestMatchIndex && selectionStart <= closestMatchIndex + currentMatch.length) {
const substring = value.substring(closestMatchIndex, selectionStart)
substring.length > 1 && this.pushEventTo(this.el, 'fetch_queryable', {
term: substring.slice(1), match_index: currentMatch.index
})
}
this.pushEventTo(this.el, 'calculated_margin', { margin: calculatePosition(selectionStart) })
}
})
// Helper functions
const calculatePosition = position => Math.min(position, 22) * 7.2
const getIndexes = (str, regex) => {
let result
const indexes = []
while ((result = regex.exec(str))) {
indexes.push({ match: result[0], index: result.index, length: result[0].length })
}
return indexes
}
const findClosestNumber = (array, target) => {
const n = array.length
// Corner cases
if (target <= array[0]) return array[0]
if (target >= array[n - 1]) return array[n - 1]
// Doing binary search
let i = 0
let j = n
let mid = 0
while (i < j) {
mid = (i + j) / 2
if (array[mid] === target) return array[mid]
// If target is less than array
// element,then search in left
if (target < array[mid]) {
// If target is greater than previous
// to mid, return closest of two
if (mid > 0 && target > array[mid - 1]) {
return getClosest(array[mid - 1], array[mid], target)
}
// Repeat for left half
j = mid
} else {
// If target is greater than mid
if (mid < n - 1 && target < array[mid + 1]) {
return getClosest(array[mid], array[mid + 1], target)
}
i = mid + 1 // update i
}
}
// Only single element left after search
return array[mid]
}
const getClosest = (a, b, targetValue) => (targetValue - a >= b - targetValue) ? b : a
Bonus
Original intention
The reason why the input is called input-detection
is because my initial idea was to detect inputs written between square brackets and detect them via Regex. It actually does both now.
Validations
If you want to make sure that every term written in your notes
field is valid, you can write a custom validation function.
# frame_data.ex
defp validate_terms(%Ecto.Changeset{} = changeset) do
notes = Ecto.Changeset.get_field(changeset, :notes)
if is_nil(notes) do
changeset
else
Movelist.pound_regex()
|> Regex.scan(notes)
|> List.flatten()
|> Enum.map(&String.replace(&1, "-", " "))
|> Enum.map(&String.slice(&1, 1..-1//1))
|> Enum.all?(&Movelist.terms_exist?/1)
|> maybe_add_term_error(changeset)
end
end
defp maybe_add_term_error(true, changeset), do: changeset
defp maybe_add_term_error(false, changeset) do
add_error(changeset, :notes, "One of the given term does not exist")
end
Then the Ecto query looks like this
def terms_exist?(term) do
stance_exists? =
Stance
|> where([s], fragment("lower(?)", s.name) == ^term)
|> Repo.exists?()
state_exists? =
State
|> where([s], fragment("lower(?)", s.name) == ^term)
|> Repo.exists?()
stance_exists? or state_exists?
end
Now, whenever you start typing, you will receive an error message if the term does not exist.
Challenges encountered
The way it is implemented now, whenever you select a term in the dropdown, you lose focus on the initial input. I tried to fix by using the focus()
function from both the Javascript or JS.focus/1
, unfortunately, the focus works but the update_notes
event is no longer fired.
I also wanted to add arrow keys navigation on the dropdown but quickly gave up since I wanted to focus on finishing more important parts of the app. I will probably update this article once I revisit this idea.
Alright, now it is truly the end.
Tekken Teacher is a solo project that I initially started to learn Elixir a few years ago now. If you are a designer or a developer and would like to get involved in this project, shoot me a message, I would like to collaborate.
If you have never played fighting game because you are intimidated by it or never had the opportunity, have a try ! I guarantee that you will have fun. Don't focus on winning, but improving on a few things at a time ! 😄
Happy new year ! 🎉
Subscribe
A developer's (inconsistent) journey
No spam. Unsubscribe anytime.