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

Support multi-model pages #299

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
34 changes: 23 additions & 11 deletions assets/js/keepalive.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
/*
** keepalive.js // v1.0.0 // 6th January 2022
** keepalive.js // v1.1.0 // 11 November 2024
** Keeps alive the websocket connection by sending a ping every x seconds
** where x = Genie.config.webchannels_keepalive_frequency
*/

function keepalive() {
if (window._lastMessageAt !== undefined) {
if (Date.now() - window._lastMessageAt < Genie.Settings.webchannels_keepalive_frequency) {
return
function keepalive(WebChannel) {
if (WebChannel.lastMessageAt !== undefined) {
dt = Date.now() - WebChannel.lastMessageAt;
// allow for a 200ms buffer
if (dt + 200 < Genie.Settings.webchannels_keepalive_frequency) {
keepaliveTimer(WebChannel, Genie.Settings.webchannels_keepalive_frequency - dt);
return;
}
}

if (Genie.Settings.env == 'dev') {
console.info('Keeping connection alive');
if (!WebChannel.ws_disconnected) {
if (Genie.Settings.env == 'dev') {
console.info('Keeping connection alive');
}
WebChannel.sendMessageTo(WebChannel.channel, 'keepalive', {
'payload': {}
});
}

Genie.WebChannels.sendMessageTo(CHANNEL, 'keepalive', {
'payload': {}
});
}

function keepaliveTimer(WebChannel, startDelay = Genie.Settings.webchannels_keepalive_frequency) {
clearInterval(WebChannel.keepalive_interval);
setTimeout(() => {
keepalive(WebChannel);
WebChannel.keepalive_interval = setInterval(() => keepalive(WebChannel), Genie.Settings.webchannels_keepalive_frequency);
}, startDelay)
}
4 changes: 2 additions & 2 deletions assets/js/watchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const watcherMixin = {
},

push: function (field) {
Genie.WebChannels.sendMessageTo(CHANNEL, 'watchers', {'payload': {
this.WebChannel.sendMessageTo(this.channel_, 'watchers', {'payload': {
'field': field,
'newval': this[field],
'oldval': null,
Expand Down Expand Up @@ -110,7 +110,7 @@ const eventMixin = {
if (event_data === undefined) { event_data = {} }
console.debug('event: ' + JSON.stringify(event_data) + ":" + event_handler)
if (mode=='addclient') { event_data._addclient = true}
Genie.WebChannels.sendMessageTo(window.CHANNEL, 'events', {
this.WebChannel.sendMessageTo(this.channel_, 'events', {
'event': {
'name': event_handler,
'event': event_data
Expand Down
136 changes: 67 additions & 69 deletions src/Elements.jl
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ function vue_integration(::Type{M};
debounce::Int = Stipple.JS_DEBOUNCE_TIME,
transport::Module = Genie.WebChannels)::String where {M<:ReactiveModel}
model = Base.invokelatest(M)

vue_app = json(model |> Stipple.render)
vue_app = replace(vue_app, "\"$(getchannel(model))\"" => Stipple.channel_js_name)

Expand All @@ -157,89 +156,88 @@ function vue_integration(::Type{M};

output =
string(
"

function initStipple(rootSelector){
// components = Stipple.init($( core_theme ? "{theme: '$theme'}" : "" ));
const app = Vue.createApp($( replace(vue_app, "'$(Stipple.UNDEFINED_PLACEHOLDER)'"=>Stipple.UNDEFINED_VALUE) ))
/* Object.entries(components).forEach(([key, value]) => {
app.component(key, value)
}); */
Stipple.init( app, $( core_theme ? "{theme: '$theme'}" : "" ));
$globalcomps
$comps
// gather legacy global options
app.prototype = {}
$(plugins(M))
// apply legacy global options
Object.entries(app.prototype).forEach(([key, value]) => {
app.config.globalProperties[key] = value
});
window.$vue_app_name = window.GENIEMODEL = app.mount(rootSelector);
} // end of initStipple

"

,

"

function initWatchers(){
"

,
join(
[Stipple.watch(string("window.", vue_app_name), field, Stipple.channel_js_name, debounce, model) for field in fieldnames(Stipple.get_concrete_type(M))
if Stipple.has_frontend_watcher(field, model)]
)
,

"
} // end of initWatchers

"

,

"""

window.parse_payload = function(payload){
if (payload.key) {
window.$(vue_app_name).updateField(payload.key, payload.value);
window.parse_payload = function(WebChannel, payload){
if (payload.key) {
WebChannel.parent.updateField(payload.key, payload.value);
}
}
}

function app_ready() {
$vue_app_name.channel_ = window.CHANNEL;
$vue_app_name.isready = true;
Genie.Revivers.addReviver(window.$(vue_app_name).revive_jsfunction);
$(transport == Genie.WebChannels &&
"

function app_ready(app) {
if (app.WebChannel == Genie.AllWebChannels[0]) Genie.Revivers.addReviver(app.revive_jsfunction);
app.isready = true;
""",
transport == Genie.WebChannels &&
"""
try {
if (Genie.Settings.webchannels_keepalive_frequency > 0) {
clearInterval($vue_app_name.keepalive_interval);
$vue_app_name.keepalive_interval = setInterval(keepalive, Genie.Settings.webchannels_keepalive_frequency);
keepaliveTimer(app.WebChannel, 0);
}
} catch (e) {
if (Genie.Settings.env === 'dev') {
console.error('Error setting WebSocket keepalive interval: ' + e);
}
}
")

""",
"""
if (Genie.Settings.env === 'dev') {
console.info('App starting');
}
};
};

if ( window.autorun === undefined || window.autorun === true ) {
initStipple('#$vue_app_name');
initWatchers();
function initStipple$vue_app_name(appName, rootSelector, channel){
// components = Stipple.init($( core_theme ? "{theme: '$theme'}" : "" ));
const app = Vue.createApp($( replace(vue_app, "'$(Stipple.UNDEFINED_PLACEHOLDER)'"=>Stipple.UNDEFINED_VALUE) ))
/* Object.entries(components).forEach(([key, value]) => {
app.component(key, value)
}); */
Stipple.init( app, $( core_theme ? "{theme: '$theme'}" : "" ));
$globalcomps
$comps
// gather legacy global options
app.prototype = {}
$(plugins(M))
// apply legacy global options
Object.entries(app.prototype).forEach(([key, value]) => {
app.config.globalProperties[key] = value
});

const stippleApp = window[appName] = window.GENIEMODEL = app.mount(rootSelector);
stippleApp.WebChannel = Genie.initWebChannel(channel);
stippleApp.WebChannel.parent = stippleApp;
stippleApp.channel_ = channel;

return stippleApp;
} // end of initStipple

function initWatchers$vue_app_name(app){
""",
join(
[Stipple.watch("app", field, Stipple.channel_js_name, debounce, model) for field in fieldnames(Stipple.get_concrete_type(M))
if Stipple.has_frontend_watcher(field, model)]
),

"""
} // end of initWatchers

function create$vue_app_name(channel) {
window.counter$vue_app_name = window.counter$vue_app_name || 1
const appName = '$vue_app_name' + ((counter$vue_app_name == 1) ? '' : '_' + window.counter$vue_app_name)
const rootSelector = '#$vue_app_name' + ((counter$vue_app_name == 1) ? '' : '-' + window.counter$vue_app_name)
counter$vue_app_name++

if ( window.autorun === undefined || window.autorun === true ) {
app = initStipple$vue_app_name(appName, rootSelector, channel);
initWatchers$vue_app_name(app);

app.WebChannel.subscriptionHandlers.push(function(event) {
app_ready(app);
});
}
}

Genie.WebChannels.subscriptionHandlers.push(function(event) {
app_ready();
});
}
// create$vue_app_name()
// is called via script with addEventListener to support multiple apps
"""
)

Expand Down
47 changes: 41 additions & 6 deletions src/Layout.jl
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ const THEMES = Ref(Function[])

const FLEXGRID_KWARGS = [:col, :xs, :sm, :md, :lg, :xl, :gutter, :xgutter, :ygutter]

"""
make_unique!(src::Vector, condition::Union{Nothing, Function} = nothing)

Utility function for removing duplicates from a vector that fulfill a given condition.
"""
function make_unique!(src::Vector, condition::Union{Nothing, Function} = nothing)
seen = Int[]
dups = Int[]
for (i, name) in enumerate(src)
if name ∈ view(src, seen) && (condition === nothing || condition(name))
push!(dups, i)
else
push!(seen, i)
end
end

deleteat!(src, dups)
end

"""
function layout(output::Union{String,Vector}; partial::Bool = false, title::String = "", class::String = "", style::String = "",
head_content::String = "", channel::String = Genie.config.webchannels_default_route) :: String
Expand All @@ -41,19 +60,22 @@ julia> layout([
"<link href=\"https://fonts.googleapis.com/css?family=Material+Icons\" rel=\"stylesheet\" /><link href=\"https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;0,900;1,400&display=swap\" rel=\"stylesheet\" /><link href=\"/css/stipple/stipplecore.css\" rel=\"stylesheet\" /><link href=\"/css/stipple/quasar.min.css\" rel=\"stylesheet\" /><span v-text='greeting'>Hello</span><script src=\"/js/channels.js?v=1.17.1\"></script><script src=\"/js/underscore-min.js\"></script><script src=\"/js/vue.global.prod.js\"></script><script src=\"/js/quasar.umd.prod.js\"></script>\n<script src=\"/js/apexcharts.min.js\"></script><script src=\"/js/vue-apexcharts.min.js\"></script><script src=\"/js/stipplecore.js\" defer></script><script src=\"/js/vue_filters.js\" defer></script>"
```
"""
function layout(output::Union{S,Vector}, m::M;
function layout(output::Union{S,Vector}, m::Union{M, Vector{M}};
partial::Bool = false, title::String = "", class::String = "", style::String = "", head_content::Union{AbstractString, Vector{<:AbstractString}} = "",
channel::String = Stipple.channel_js_name,
core_theme::Bool = true)::ParsedHTMLString where {M<:ReactiveModel, S<:AbstractString}

isa(output, Vector) && (output = join(output, '\n'))
m isa Vector || (m = [m])

content = [
output
theme(; core_theme)
Stipple.deps(m)
Stipple.deps.(m)...
]

make_unique!(content, contains(r"src=|href="i))

partial && return content

Genie.Renderer.Html.doc(
Expand Down Expand Up @@ -84,21 +106,34 @@ julia> page(:elemid, [
"<!DOCTYPE html>\n<html><head><title></title><meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui\" /></head><body class style><link href=\"https://fonts.googleapis.com/css?family=Material+Icons\" rel=\"stylesheet\" /><link href=\"https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;0,900;1,400&display=swap\" rel=\"stylesheet\" /><link href=\"/css/stipple/stipplecore.css\" rel=\"stylesheet\" /><link href=\"/css/stipple/quasar.min.css\" rel=\"stylesheet\" /><div id=elemid><span v-text='greeting'>Hello</span></div><script src=\"/js/channels.js?v=1.17.1\"></script><script src=\"/js/underscore-min.js\"></script><script src=\"/js/vue.global.prod.js\"></script><script src=\"/js/quasar.umd.prod.js\"></script>\n<script src=\"/js/apexcharts.min.js\"></script><script src=\"/js/vue-apexcharts.min.js\"></script><script src=\"/js/stipplecore.js\" defer></script><script src=\"/js/vue_filters.js\" defer></script></body></html>"
```
"""
function page(model::M, args...;
function page(model::Union{M, Vector{M}}, args...;
pagetemplate = (x...) -> join([x...], '\n'),
partial::Bool = false, title::String = "", class::String = "container", style::String = "",
channel::String = Genie.config.webchannels_default_route, head_content::Union{AbstractString, Vector{<:AbstractString}} = "",
prepend::Union{S,Vector} = "", append::Union{T,Vector} = [],
core_theme::Bool = true,
kwargs...)::ParsedHTMLString where {M<:Stipple.ReactiveModel, S<:AbstractString,T<:AbstractString}
model isa Vector || (model = [model])
uis = if !isempty(args)
args[1] isa Vector ? args[1] : [args[1]]
else
""
end
counter = Dict{DataType, Int}()

function rootselector(m::M) where M <:ReactiveModel
AM = Stipple.get_abstract_type(M)
counter[AM] = get(counter, AM, 0) + 1
return (counter[AM] == 1) ? vm(m) : "$(vm(m))-$(counter[AM])"
end

layout(
[
join(prepend)
Genie.Renderer.Html.div(id = vm(M), args...; class = class, kwargs...)
pagetemplate([Genie.Renderer.Html.div(id = rootselector(m), ui, args[2:end]...; class = class, kwargs...) for (m, ui) in zip(model, uis)]...)
join(append)
], model;
partial = partial, title = title, style = style, head_content = head_content, channel = channel,
core_theme = core_theme)
partial, title, style, head_content, channel, core_theme)
end

const app = page
Expand Down
19 changes: 13 additions & 6 deletions src/Stipple.jl
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ using .NamedTuples
export JSONParser, JSONText, json, @json, jsfunction, @jsfunction_str

const config = Genie.config
const channel_js_name = "window.CHANNEL"
const channel_js_name = "'not_assigned'"

const OptDict = OrderedDict{Symbol, Any}
opts(;kwargs...) = OptDict(kwargs...)
Expand Down Expand Up @@ -342,10 +342,10 @@ changed on the frontend, it is pushed over to the backend using `channel`, at a
function watch(vue_app_name::String, fieldname::Symbol, channel::String, debounce::Int, model::M; jsfunction::String = "")::String where {M<:ReactiveModel}
js_channel = isempty(channel) ?
"window.Genie.Settings.webchannels_default_route" :
(channel == Stipple.channel_js_name ? Stipple.channel_js_name : "'$channel'")
"$vue_app_name.channel_"

isempty(jsfunction) &&
(jsfunction = "Genie.WebChannels.sendMessageTo($js_channel, 'watchers', {'payload': {'field':'$fieldname', 'newval': newVal, 'oldval': oldVal, 'sesstoken': document.querySelector(\"meta[name='sesstoken']\")?.getAttribute('content')}});")
(jsfunction = "$vue_app_name.push('$fieldname')")

output = IOBuffer()
if fieldname == :isready
Expand Down Expand Up @@ -845,11 +845,18 @@ function injectdeps(output::Vector{AbstractString}, M::Type{<:ReactiveModel}) ::
output
end


# no longer needed, replaced by initscript
function channelscript(channel::String) :: String
Genie.Renderer.Html.script(["window.CHANNEL = '$(channel)';"])
Genie.Renderer.Html.script(["""
document.addEventListener('DOMContentLoaded', () => window.Genie.initWebChannel('$channel') );
"""])
end

function initscript(vue_app_name, channel) :: String
Genie.Renderer.Html.script(["""
document.addEventListener('DOMContentLoaded', () => window.create$vue_app_name('$channel') );
"""])
end

"""
function deps(channel::String = Genie.config.webchannels_default_route)
Expand All @@ -859,7 +866,7 @@ Outputs the HTML code necessary for injecting the dependencies in the page (the
function deps(m::M) :: Vector{String} where {M<:ReactiveModel}
channel = getchannel(m)
output = [
channelscript(channel),
initscript(vm(m), channel),
(is_channels_webtransport() ? Genie.Assets.channels_script_tag(channel) : Genie.Assets.webthreads_script_tag(channel)),
Genie.Renderer.Html.script(src = Genie.Assets.asset_path(assets_config, :js, file="underscore-min")),
Genie.Renderer.Html.script(src = Genie.Assets.asset_path(assets_config, :js, file=(Genie.Configuration.isprod() ? "vue.global.prod" : "vue.global"))),
Expand Down
Loading
Loading