Skip to content
This repository has been archived by the owner on May 22, 2020. It is now read-only.

Dispatch actions on runtime #3

Merged
merged 10 commits into from
Apr 29, 2018
109 changes: 39 additions & 70 deletions lib/frankt.ex
Original file line number Diff line number Diff line change
Expand Up @@ -68,89 +68,58 @@ defmodule Frankt do
"""
@type response_handler :: ((params :: map(), socket :: Phoenix.Socket.t()) -> any())

defmacro __using__(opts) do
defmacro __using__(_opts) do
quote do
Module.register_attribute __MODULE__, :responses, accumulate: true
Module.put_attribute __MODULE__, :gettext, unquote(Keyword.get(opts, :gettext))

import Frankt, only: [defresponse: 2]

@before_compile unquote(__MODULE__)
def handle_in("frankt-action", params = %{"action" => action}, socket) do
[handler_name, handler_fn] = String.split(action, ":")
handler_module = Frankt.__handler__(__MODULE__, handler_name)
gettext = Frankt.__gettext__(__MODULE__)
data = Map.get(params, "data", %{})

Frankt.__execute_action__(handler_module, String.to_existing_atom(handler_fn), data, socket, gettext)
{:noreply, socket}
end
end
end

@doc """
Define a response to client-side triggers.

This macro generates the code that call the response handler `function` when `message` is received.

Take into account that to make a certain response behave differently depending on the received
params you must pattern match in the response handler `function` instead of using a second
`defresponse`. For more information take a look at `t:response_handler/0`.
"""
@spec defresponse(message :: String.t(), function :: response_handler()) :: any()
defmacro defresponse(message, function) do
quote do
Module.put_attribute(__MODULE__, :responses, unquote(message))

def execute_response(unquote(message), params, socket) do
Frankt.__execute_response__(unquote(function), params, socket, @gettext)
@doc false
def __execute_action__(module, fun, params, socket, gettext) do
invoke_action = fn ->
unless function_exported?(module, fun, 2) do
raise "Frankt is trying to execute an action, but the handler module does not define the appropriate function. Please define a '#{fun}/2' function in your ·#{module} module."
end
apply(module, fun, [params, socket])
end
end

# Before the compilation takes place we need to generate the module's `use`
# handler so it can be used inside the Frankt channel with the response
# handlers exported correctly.

# It's important to point that this process should be done just before the
# compilation takes place to:
# * Be able to generate a bit more of code that go into the compilation
# * Be able to read responses storage to kow which ones needs to inject in the
# socket channel.
defmacro __before_compile__(_) do
quote do
defmacro __using__(_) do
Enum.map(@responses, fn message ->
quote do
def handle_in(unquote(message), params, socket) do
unquote(__MODULE__).execute_response(unquote(message), params, socket)
end
end
end)
end
if gettext do
locale =
case Map.get(socket.assigns, :locale) do
nil -> raise "You have configured Frankt to use Gettext for i18n, but the response does not know which locale to use. Please store the desired locale into a `locale` assign in the socket."
locale -> locale
end
Gettext.with_locale(gettext, locale, invoke_action)
else
invoke_action.()
end
end

@doc """
Build the topic name for the Frankt channel.

The topic name is used when connecting clients to Frankt. It can also be used in other
circumstances such broadcasting server-side updates for certain users.

The `client` variable can be any value used to identify each connection (for example the
connected user ID). This variable will be base16 encoded for privacy.
"""
@spec topic_name(client :: String.t()) :: String.t()
def topic_name(client), do: "frankt:#{:md5 |> :crypto.hash(client) |> Base.encode16()}"

@doc false
def __execute_response__(function, params, socket, nil) do
function.(params, socket)
{:noreply, socket}
end
def __execute_response__(function, params, socket, gettext) do
Gettext.with_locale(gettext, get_locale(socket), fn ->
function.(params, socket)
{:noreply, socket}
end)
def __handler__(frankt_module, name) when is_binary(name) do
handlers = Application.get_env(:frankt, frankt_module)
if is_nil(handlers) do
raise "You have not configured any handlers for Frankt. Please set at least one handler in your configuration."
end
case get_in(handlers, [:handlers, String.to_existing_atom(name)]) do
nil -> "Frankt can not find a handler for '#{name}'. Please, chech that you are using the correct name or define a new handler in your configuration."
handler -> handler
end
end

defp get_locale(socket) do
case Map.get(socket.assigns, :locale) do
nil -> raise "You have configured Frankt to use Gettext for i18n, but the response does not know which locale to use. Please store the desired locale into a `locale` assign in the socket."
locale -> locale
end
@doc false
def __gettext__(frankt_module) do
:frankt
|> Application.get_env(frankt_module, [])
|> Keyword.get(:gettext)
end

end
30 changes: 30 additions & 0 deletions lib/test/action_test.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Frankt.ActionTest do
@moduledoc """
Conveniences for testing Frankt actions.

Frankt tests are actually channel tests. For more information take a look at
`Phoenix.ChannelTest`.
"""

@doc false
defmacro __using__(_opts) do
quote do
import Frankt.ActionTest
end
end

@doc """
Call a Frankt action.

Pushes a mesasge into the channel which triggers the Frankt `handle_in/3` function and then
dispatches to the corresponding action.
After pushing the message to Frankt you can check the response by using
`Phoenix.ChannelTest.assert_push/3`.
"""
@spec frankt_action(socket :: Socket.t, action :: String.t, payload :: map()) :: reference()
defmacro frankt_action(socket, action, payload \\ %{}) do
quote do
push(unquote(socket), "frankt-action", %{action: unquote(action), data: unquote(payload)})
end
end
end
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"homepage": "https://github.com/acutario/frankt#readme",
"dependencies": {
"phoenix": "^1.3.0"
"phoenix": "^1.3.0",
"deep-compact": "^1.1.0"
}
}
36 changes: 15 additions & 21 deletions priv/static/dom.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import deepCompact from 'deep-compact';

const k_r_submitter = /^(?:submit|button|image|reset|file)$/i;
const k_r_success_contrls = /^(?:input|select|textarea|keygen)/i;
const brackets = /(\[[^\[\]]*\])/g;
Expand All @@ -20,8 +22,7 @@ function serializer(result, key, value) {
if (matches) {
const keys = parse_keys(key);
hash_assign(result, keys, value);
}
else {
} else {
// Non bracket notation can make assignments directly.
const existing = result[key];

Expand All @@ -33,11 +34,10 @@ function serializer(result, key, value) {
// assignment could go through `hash_assign`.
if (existing) {
if (!Array.isArray(existing)) {
result[key] = [ existing ];
result[key] = [existing];
}
result[key].push(value);
}
else {
} else {
result[key] = value;
}
}
Expand All @@ -60,7 +60,7 @@ export function serialize(form, options) {
//Object store each radio and set if it's empty or not
const radio_store = {};

for (let i=0 ; i<elements.length ; ++i) {
for (let i = 0; i < elements.length; ++i) {
var element = elements[i];

// ingore disabled fields
Expand All @@ -69,7 +69,7 @@ export function serialize(form, options) {
}
// ignore anyhting that is not considered a success field
if (!k_r_success_contrls.test(element.nodeName) ||
k_r_submitter.test(element.type)) {
k_r_submitter.test(element.type)) {
continue;
}

Expand All @@ -93,8 +93,7 @@ export function serialize(form, options) {
if (element.type === 'radio') {
if (!radio_store[element.name] && !element.checked) {
radio_store[element.name] = false;
}
else if (element.checked) {
} else if (element.checked) {
radio_store[element.name] = true;
}
}
Expand All @@ -103,8 +102,7 @@ export function serialize(form, options) {
if (val == undefined && element.type == 'radio') {
continue;
}
}
else {
} else {
// value-less fields are ignored unless options.empty is true
if (!val) {
continue;
Expand All @@ -117,7 +115,7 @@ export function serialize(form, options) {

var selectOptions = element.options;
var isSelectedOptions = false;
for (var j=0 ; j<selectOptions.length ; ++j) {
for (let j = 0; j < selectOptions.length; ++j) {
var option = selectOptions[j];
var allowedEmpty = options.empty && !option.value;
var hasValue = (option.value || allowedEmpty);
Expand All @@ -131,8 +129,7 @@ export function serialize(form, options) {
// "foo" and "foo[]" should be arrays.
if (key.slice(key.length - 2) !== '[]') {
result = serializer(result, key + '[]', option.value);
}
else {
} else {
result = serializer(result, key, option.value);
}
}
Expand All @@ -158,7 +155,7 @@ export function serialize(form, options) {
}
}

return result;
return deepCompact(result);
}

function parse_keys(string) {
Expand Down Expand Up @@ -192,8 +189,7 @@ function hash_assign(result, keys, value) {

if (Array.isArray(result)) {
result.push(hash_assign(null, keys, value));
}
else {
} else {
// This might be the result of bad name attributes like "[][foo]",
// in this case the original `result` object will already be
// assigned to an object literal. Rather than coerce the object to
Expand All @@ -209,8 +205,7 @@ function hash_assign(result, keys, value) {
// Key is an attribute name and can be assigned directly.
if (!between) {
result[key] = hash_assign(result[key], keys, value);
}
else {
} else {
const string = between[1];
// +var converts the variable into a number
// better than parseInt because it doesn't truncate away trailing
Expand All @@ -222,8 +217,7 @@ function hash_assign(result, keys, value) {
if (isNaN(index)) {
result = result || {};
result[string] = hash_assign(result[string], keys, value);
}
else {
} else {
result = result || [];
result[index] = hash_assign(result[index], keys, value);
}
Expand Down
11 changes: 7 additions & 4 deletions priv/static/frankt.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ export let channel = {};

export function sendMsg(action, data) {
if (channel.state === 'closed') init(true);
return channel.push(action, data);
return channel.push('frankt-action', {action: action, data: data});
}

export function serializeForm(element) {
const data = [];
data.push({csrf_token: document.querySelector('meta[name=csrf]').content});
const data = [{}];

if (element.name && element.tagName !== "INPUT") {
data.push(Dom.serializeElement(element));
}

if (element.dataset.franktData) {
data.push($(element).data('franktData'));
}

if (element.dataset.franktTarget) {
const target = document.querySelector(element.dataset.franktTarget);
// Block submit form on enter
Expand All @@ -32,7 +35,7 @@ export function serializeForm(element) {
}

function handleEvent(e, selector) {
if (e.target.matches(selector) || e.target.closest(selector)){
if (e.target.matches(selector) || e.target.closest(selector)) {
const target = e.target.matches(selector) ? e.target : e.target.closest(selector);
e.preventDefault();
const data = serializeForm(target);
Expand Down
8 changes: 0 additions & 8 deletions test/frankt_test.exs

This file was deleted.