How to create Github-like references to your database records with a Liveview hook

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.

Simplified slice of the database schema

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 :

# movelist.ex

def list_queryable_for(character_id, term) do
  stance_query =
    character_id
    |> Stance.stances_for_character_query()
    |> select([s], %{name: s.name, notation: s.notation})
    |> where([s], ilike(s.name, ^"%#{strip_term(term)}%"))
    |> order_by([s], s.name)

  State
  |> select([sta], %{name: sta.name, notation: sta.notation})
  |> where([sta], ilike(sta.name, ^"%#{strip_term(term)}%"))
  |> union(^stance_query)
  |> Repo.all()
end

# iex> Movelist.list_queryable_for(8, "st")
# iex> [
# iex> %{name: "During sidestep", notation: "SS"}, 
# iex> %{name: "While standing", notation: "WS"}, 
# iex> %{name: "Starburst", notation: "STB"}
# iex> ]

Query used on the input-detection input type

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 ! 🎉

Show Comments