Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Clientside callbacks #30

Merged
merged 3 commits into from
May 26, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. This projec

### [UNRELEASED]
### Added
- Support for client side callbacks [#30](https://github.com/plotly/Dash.jl/pull/30)
- Support for hot reloading on application or asset changes [#25](https://github.com/plotly/Dash.jl/pull/25)
- Asset serving of CSS, JavaScript, and other resources [#18](https://github.com/plotly/Dash.jl/pull/18)
- Support for passing functions as layouts [#18](https://github.com/plotly/Dash.jl/pull/18)
Expand Down
2 changes: 1 addition & 1 deletion src/Dash.jl
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import .Front
using .Components

export dash, Component, Front, <|, @callid_str, CallbackId, callback!,
enable_dev_tools!,
enable_dev_tools!, ClientsideFunction,
run_server, PreventUpdate, no_update, @var_str


Expand Down
31 changes: 26 additions & 5 deletions src/app/callbacks.jl
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,32 @@ end


"""
function callback!(func::Function, app::DashApp, id::CallbackId)
function callback!(func::Union{Function, ClientsideFunction, String}, app::DashApp, id::CallbackId)

check_callback(func, app, id)

out_symbol = Symbol(output_string(id))

push!(app.callbacks, out_symbol => Callback(func, id))
callback_func = make_callback_func!(app, func, id)
push!(app.callbacks, out_symbol => Callback(callback_func, id))
end

make_callback_func!(app::DashApp, func::Union{Function, ClientsideFunction}, id::CallbackId) = func

function check_callback(func::Function, app::DashApp, id::CallbackId)
function make_callback_func!(app::DashApp, func::String, id::CallbackId)
first_output = first(id.output)
namespace = replace("_dashprivate_$(first_output[1])", "\""=>"\\\"")
function_name = replace("$(first_output[2])", "\""=>"\\\"")

function_string = """
var clientside = window.dash_clientside = window.dash_clientside || {};
var ns = clientside["$namespace"] = clientside["$namespace"] || {};
ns["$function_name"] = $func;
"""
push!(app.inline_scripts, function_string)
return ClientsideFunction(namespace, function_name)
end

function check_callback(func, app::DashApp, id::CallbackId)



Expand All @@ -75,9 +90,15 @@ function check_callback(func::Function, app::DashApp, id::CallbackId)

args_count = length(id.state) + length(id.input)

!hasmethod(func, NTuple{args_count, Any}) && error("The arguments of the specified callback function do not align with the currently defined callback; please ensure that the arguments to `func` are properly defined.")
check_callback_func(func, args_count)

for id_prop in id.input
id_prop in id.output && error("Circular input and output arguments were found. Please verify that callback outputs are not also input arguments.")
end
end

function check_callback_func(func::Function, args_count)
!hasmethod(func, NTuple{args_count, Any}) && error("The arguments of the specified callback function do not align with the currently defined callback; please ensure that the arguments to `func` are properly defined.")
end
function check_callback_func(func, args_count)
end
4 changes: 3 additions & 1 deletion src/app/dashapp.jl
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ mutable struct DashApp
layout ::Union{Nothing, Component, Function}
devtools ::DevTools
callbacks ::Dict{Symbol, Callback}
inline_scripts ::Vector{String}

DashApp(root_path, is_interactive, config, index_string, title = "Dash") = new(root_path, is_interactive, config, index_string, title, nothing, DevTools(dash_env(Bool, "debug", false)), Dict{Symbol, Callback}())
DashApp(root_path, is_interactive, config, index_string, title = "Dash") =
new(root_path, is_interactive, config, index_string, title, nothing, DevTools(dash_env(Bool, "debug", false)), Dict{Symbol, Callback}(), String[])

end

Expand Down
7 changes: 5 additions & 2 deletions src/app/supporttypes.jl
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ CallbackId(;input,


Base.convert(::Type{Vector{IdProp}}, v::IdProp) = [v]

struct ClientsideFunction
namespace ::String
function_name ::String
end
struct Callback
func ::Function
func ::Union{Function, ClientsideFunction}
id ::CallbackId
end

Expand Down
12 changes: 9 additions & 3 deletions src/handler/index_page.jl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ make_css_tag(app::DashApp, resource::AppResource) = make_css_tag(resource_url(ap
make_script_tag(url::String) = """<script src="$(url)"></script>"""
make_script_tag(app::DashApp, resource::AppCustomResource) = format_tag("script", resource.params)
make_script_tag(app::DashApp, resource::AppResource) = make_script_tag(resource_url(app, resource))
make_inline_script_tag(script::String) = """<script>$(script)</script>"""

function metas_html(app::DashApp)
meta_tags = app.config.meta_tags
Expand All @@ -58,9 +59,14 @@ function css_html(app::DashApp, resources::ApplicationResources)
end

function scripts_html(app::DashApp, resources::ApplicationResources)
join(
make_script_tag.(Ref(app), resources.js), "\n "
)
include_string = join(
vcat(
make_script_tag.(Ref(app), resources.js),
make_inline_script_tag.(app.inline_scripts)
),
"\n "
)

end

app_entry_html() = """
Expand Down
5 changes: 4 additions & 1 deletion src/handler/state.jl
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,15 @@ mutable struct StateCache
StateCache(app, registry) = new(_cache_tuple(app, registry)..., false)
end

_dep_clientside_func(func::ClientsideFunction) = func
_dep_clientside_func(func) = nothing
function _dependencies_json(app::DashApp)
id_prop_named(p::IdProp) = (id = p[1], property = p[2])
result = map(values(app.callbacks)) do dep
(inputs = id_prop_named.(dep.id.input),
state = id_prop_named.(dep.id.state),
output = output_string(dep.id)
output = output_string(dep.id),
clientside_function = _dep_clientside_func(dep.func)
)
end
return JSON2.write(result)
Expand Down
208 changes: 208 additions & 0 deletions test/callbacks.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import HTTP, JSON2
using Test
using Dash

@testset "callid" begin
id = callid"id1.prop1 => id2.prop2"
@test id isa CallbackId
@test length(id.state) == 0
@test length(id.input) == 1
@test length(id.output) == 1
@test id.input[1] == (:id1, :prop1)
@test id.output[1] == (:id2, :prop2)

id = callid"{state1.prop1, state2.prop2} input1.prop1, input2.prop2 => output1.prop1, output2.prop2"
@test id isa CallbackId
@test length(id.state) == 2
@test length(id.input) == 2
@test length(id.output) == 2
@test id.input[1] == (:input1, :prop1)
@test id.input[2] == (:input2, :prop2)
@test id.output[1] == (:output1, :prop1)
@test id.output[2] == (:output2, :prop2)
@test id.state[1] == (:state1, :prop1)
@test id.state[2] == (:state2, :prop2)
end

@testset "callback!" begin
app = dash()
app.layout = html_div() do
dcc_input(id = "my-id", value="initial value", type = "text"),
html_div(id = "my-div")
end

callback!(app, callid"my-id.value => my-div.children") do value
return value
end
@test length(app.callbacks) == 1
@test haskey(app.callbacks, Symbol("my-div.children"))
@test app.callbacks[Symbol("my-div.children")].func("test") == "test"

handler = make_handler(app)
request = HTTP.Request("GET", "/_dash-dependencies")
resp = HTTP.handle(handler, request)
deps = JSON2.read(String(resp.body))

@test length(deps) == 1
cb = deps[1]
@test cb.output == "my-div.children"
@test cb.inputs[1].id == "my-id"
@test cb.inputs[1].property == "value"
@test :clientside_function in keys(cb)
@test isnothing(cb.clientside_function)

app = dash()
app.layout = html_div() do
dcc_input(id = "my-id", value="initial value", type = "text"),
html_div(id = "my-div"),
html_div(id = "my-div2")
end
callback!(app, callid"{my-id.type} my-id.value => my-div.children, my-div2.children") do state, value
return state, value
end
@test length(app.callbacks) == 1
@test haskey(app.callbacks, Symbol("..my-div.children...my-div2.children.."))
@test app.callbacks[Symbol("..my-div.children...my-div2.children..")].func("state", "value") == ("state", "value")

app = dash()

app.layout = html_div() do
dcc_input(id = "my-id", value="initial value", type = "text"),
html_div(id = "my-div"),
html_div(id = "my-div2")
end

callback!(app, callid"my-id.value => my-div.children") do value
return value
end
callback!(app, callid"my-id.value => my-div2.children") do value
return "v_$(value)"
end

@test length(app.callbacks) == 2
@test haskey(app.callbacks, Symbol("my-div.children"))
@test haskey(app.callbacks, Symbol("my-div2.children"))
@test app.callbacks[Symbol("my-div.children")].func("value") == "value"
@test app.callbacks[Symbol("my-div2.children")].func("value") == "v_value"

@test_throws ErrorException callback!(app, callid"my-id.value => my-div2.children") do value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me exactly what's being tested by some of these @test_throws - at a minimum it would be nice to have a comment like "my-div2.children is already an output", but better would be for the code to somehow inspect the error to ensure it's actually the one you intended to be thrown, as opposed to something else like a typo in the callid string causing a regexp failure.

Minor, and not new in this PR, you're just rearranging, but it's the first I'm looking at this code 😉

return "v_$(value)"
end

@test_throws ErrorException callback!(app, callid"{my-id.value} my-id.value => my-id.value") do value
return "v_$(value)"
end

@test_throws ErrorException callback!(app, callid"my-div.children, my-id.value => my-id.value") do value
return "v_$(value)"
end
@test_throws ErrorException callback!(app, callid"my-id.value => my-div.children, my-id.value") do value
return "v_$(value)"
end

@test_throws ErrorException callback!(app, callid" => my-div2.title, my-id.value") do value
return "v_$(value)"
end

@test_throws ErrorException callback!(app, callid"my-id.value => my-div2.title, my-div2.title") do value
return "v_$(value)"
end

@test_throws ErrorException callback!(app, callid"my-id.value => my-div2.title") do
return "v_"
end


app = dash()
app.layout = html_div() do
dcc_input(id = "my-id", value="initial value", type = "text"),
html_div("test2", id = "my-div"),
html_div(id = "my-div2") do
html_h1("gggg", id = "my-h")
end
end
callback!(app, callid"{my-id.type} my-id.value => my-div.children, my-h.children") do state, value
return state, value
end
@test length(app.callbacks) == 1
end

@testset "clientside callbacks function" begin
app = dash()
app.layout = html_div() do
dcc_input(id = "my-id", value="initial value", type = "text"),
html_div(id = "my-div")
end

callback!(ClientsideFunction("namespace", "func_name"),app, callid"my-id.value => my-div.children")

@test length(app.callbacks) == 1
@test haskey(app.callbacks, Symbol("my-div.children"))
@test app.callbacks[Symbol("my-div.children")].func isa ClientsideFunction
@test app.callbacks[Symbol("my-div.children")].func.namespace == "namespace"
@test app.callbacks[Symbol("my-div.children")].func.function_name == "func_name"

@test_throws ErrorException callback!(app, callid"my-id.value => my-div.children") do value
return "v_$(value)"
end

handler = make_handler(app)
request = HTTP.Request("GET", "/_dash-dependencies")
resp = HTTP.handle(handler, request)
deps = JSON2.read(String(resp.body))

@test length(deps) == 1
cb = deps[1]
@test cb.output == "my-div.children"
@test cb.inputs[1].id == "my-id"
@test cb.inputs[1].property == "value"
@test :clientside_function in keys(cb)
@test cb.clientside_function.namespace == "namespace"
@test cb.clientside_function.function_name == "func_name"
end
@testset "clientside callbacks string" begin
app = dash()
app.layout = html_div() do
dcc_input(id = "my-id", value="initial value", type = "text"),
html_div(id = "my-div")
end

callback!(
"""
function(input_value) {
return (
parseFloat(input_value_1, 10)
);
}
"""
, app, callid"my-id.value => my-div.children"
)

@test length(app.callbacks) == 1
@test haskey(app.callbacks, Symbol("my-div.children"))
@test app.callbacks[Symbol("my-div.children")].func isa ClientsideFunction
@test app.callbacks[Symbol("my-div.children")].func.namespace == "_dashprivate_my-div"
@test app.callbacks[Symbol("my-div.children")].func.function_name == "children"
@test length(app.inline_scripts) == 1
@test occursin("clientside[\"_dashprivate_my-div\"]", app.inline_scripts[1])
@test occursin("ns[\"children\"]", app.inline_scripts[1])

handler = make_handler(app)
request = HTTP.Request("GET", "/_dash-dependencies")
resp = HTTP.handle(handler, request)
deps = JSON2.read(String(resp.body))

@test length(deps) == 1
cb = deps[1]
@test cb.output == "my-div.children"
@test cb.inputs[1].id == "my-id"
@test cb.inputs[1].property == "value"
@test :clientside_function in keys(cb)
@test cb.clientside_function.namespace == "_dashprivate_my-div"
@test cb.clientside_function.function_name == "children"
request = HTTP.Request("GET", "/")
resp = HTTP.handle(handler, request)
body = String(resp.body)
@test occursin("clientside[\"_dashprivate_my-div\"]", body)
@test occursin("ns[\"children\"]", body)
end
Loading