implement webc context protocol for lustre
This commit is contained in:
parent
389bd27b33
commit
1d95b6f917
14 changed files with 318 additions and 60 deletions
|
|
@ -1,6 +0,0 @@
|
||||||
import field_adder
|
|
||||||
|
|
||||||
pub fn main() -> Nil {
|
|
||||||
let assert Ok(_) = field_adder.register()
|
|
||||||
Nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/field_adder_component.mjs"></script>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_controller_component.mjs"></script>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script>
|
<script type="module" src="{{ settings.root_path }}/js_dist/cell_text_component.mjs"></script>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-controller.mjs"></script>
|
|
||||||
<script type="module" src="{{ settings.root_path }}/glm_dist/field_adder.mjs"></script>
|
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
|
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
|
||||||
<table class="viewer">
|
<viewer-controller root-path="{{ settings.root_path }}">
|
||||||
|
<table class="viewer">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
|
|
@ -45,5 +44,6 @@
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</viewer-controller>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
15
mise.toml
15
mise.toml
|
|
@ -16,17 +16,12 @@ run = "cargo run serve"
|
||||||
description = "Run the server. For development: `mise watch --restart serve`."
|
description = "Run the server. For development: `mise watch --restart serve`."
|
||||||
sources = ["**/*.rs", "**/*.html"]
|
sources = ["**/*.rs", "**/*.html"]
|
||||||
|
|
||||||
[tasks.vite]
|
[tasks.build-js]
|
||||||
run = "deno task build"
|
run = "sh build.sh"
|
||||||
dir = "./components"
|
dir = "./webc"
|
||||||
sources = ["**/*.ts"]
|
sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"]
|
||||||
|
|
||||||
[tasks.lustre]
|
[tasks.build-css]
|
||||||
run = "gleam run -m lustre/dev build component field_adder --outdir=../glm_dist"
|
|
||||||
dir = "./glm"
|
|
||||||
sources = ["**/*.gleam"]
|
|
||||||
|
|
||||||
[tasks.sass]
|
|
||||||
run = "sass sass/:css_dist/"
|
run = "sass sass/:css_dist/"
|
||||||
sources = ["sass/**/*.scss"]
|
sources = ["sass/**/*.scss"]
|
||||||
|
|
||||||
|
|
|
||||||
0
glm/.gitignore → webc/.gitignore
vendored
0
glm/.gitignore → webc/.gitignore
vendored
5
webc/build.sh
Normal file
5
webc/build.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Compile each .gleam file in src/ as a separate component module.
|
||||||
|
ls src | \
|
||||||
|
grep '_component.gleam' | \
|
||||||
|
xargs -I {} basename {} .gleam | \
|
||||||
|
xargs -I {} gleam run -m lustre/dev build component {} --outdir=../js_dist
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
name = "glm"
|
name = "webc"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
target = "javascript"
|
target = "javascript"
|
||||||
|
|
||||||
88
webc/src/cell_text_component.gleam
Normal file
88
webc/src/cell_text_component.gleam
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import gleam/dynamic
|
||||||
|
import gleam/dynamic/decode
|
||||||
|
import lustre.{type App}
|
||||||
|
import lustre/attribute as attr
|
||||||
|
import lustre/component
|
||||||
|
import lustre/effect.{type Effect}
|
||||||
|
import lustre/element.{type Element}
|
||||||
|
import lustre/element/html
|
||||||
|
|
||||||
|
import context
|
||||||
|
|
||||||
|
pub const name: String = "cell-text"
|
||||||
|
|
||||||
|
pub fn component() -> App(Nil, Model, Msg) {
|
||||||
|
lustre.component(init, update, view, [
|
||||||
|
component.on_attribute_change("value", fn(value) {
|
||||||
|
ParentChangedValue(value) |> Ok
|
||||||
|
}),
|
||||||
|
component.on_attribute_change("column", fn(value) {
|
||||||
|
ParentChangedColumn(value) |> Ok
|
||||||
|
}),
|
||||||
|
component.on_attribute_change("selected", fn(value) {
|
||||||
|
ParentChangedSelected(value != "") |> Ok
|
||||||
|
}),
|
||||||
|
component.on_attribute_change("editing", fn(value) {
|
||||||
|
ParentChangedEditing(value != "") |> Ok
|
||||||
|
}),
|
||||||
|
component.on_property_change("pkeys", {
|
||||||
|
decode.list(of: decode.string) |> decode.map(ParentChangedPkeys)
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Model {
|
||||||
|
Model(
|
||||||
|
root_path: String,
|
||||||
|
column: String,
|
||||||
|
pkeys: List(String),
|
||||||
|
selected: Bool,
|
||||||
|
editing: Bool,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(_) -> #(Model, Effect(Msg)) {
|
||||||
|
#(
|
||||||
|
Model(
|
||||||
|
root_path: "",
|
||||||
|
column: "",
|
||||||
|
pkeys: [""],
|
||||||
|
selected: False,
|
||||||
|
editing: False,
|
||||||
|
),
|
||||||
|
context.request_context(
|
||||||
|
context: dynamic.string("root_path"),
|
||||||
|
subscribe: False,
|
||||||
|
decoder: decode.string |> decode.map(AncestorChangedRootPath),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Msg {
|
||||||
|
AncestorChangedRootPath(String)
|
||||||
|
ParentChangedColumn(String)
|
||||||
|
ParentChangedEditing(Bool)
|
||||||
|
ParentChangedValue(String)
|
||||||
|
ParentChangedSelected(Bool)
|
||||||
|
ParentChangedPkeys(List(String))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
AncestorChangedRootPath(root_path) -> #(
|
||||||
|
Model(..model, root_path:),
|
||||||
|
effect.none(),
|
||||||
|
)
|
||||||
|
_ -> #(model, effect.none())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(model: Model) -> Element(Msg) {
|
||||||
|
element.fragment([
|
||||||
|
html.link([
|
||||||
|
attr.rel("stylesheet"),
|
||||||
|
attr.href(model.root_path <> "/css_dist/cell_text/index.css"),
|
||||||
|
]),
|
||||||
|
html.div([], [html.text(model.root_path)]),
|
||||||
|
])
|
||||||
|
}
|
||||||
17
webc/src/context.ffi.mjs
Normal file
17
webc/src/context.ffi.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
class ContextRequestEvent extends Event {
|
||||||
|
constructor(context, subscribe, callback) {
|
||||||
|
super("context-request", { bubbles: true, composed: true });
|
||||||
|
this.context = context;
|
||||||
|
this.subscribe = subscribe;
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitContextRequest(root, context, subscribe, callback) {
|
||||||
|
root.dispatchEvent(new ContextRequestEvent(context, subscribe, callback));
|
||||||
|
}
|
||||||
|
|
||||||
|
// FFI shim so that Gleam can pass around Javascript functions as dynamic values
|
||||||
|
export function callCallback(callback, value) {
|
||||||
|
callback(value);
|
||||||
|
}
|
||||||
93
webc/src/context.gleam
Normal file
93
webc/src/context.gleam
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import gleam/dynamic.{type Dynamic}
|
||||||
|
import gleam/dynamic/decode.{type Decoder}
|
||||||
|
import gleam/option.{type Option}
|
||||||
|
import lustre/attribute.{type Attribute}
|
||||||
|
import lustre/effect.{type Effect}
|
||||||
|
import lustre/event
|
||||||
|
|
||||||
|
/// WARNING: Lifecycle hooks in Lustre are currently limited to non-existent,
|
||||||
|
/// so it's not possible to unsubscribe from context updates on component
|
||||||
|
/// teardown. Be cautious using this effect with components that are not
|
||||||
|
/// expected to be permanent fixtures on a page. (Refer to
|
||||||
|
/// https://github.com/lustre-labs/lustre/issues/320.)
|
||||||
|
pub fn request_context(
|
||||||
|
context context: Dynamic,
|
||||||
|
subscribe subscribe: Bool,
|
||||||
|
decoder decoder: Decoder(msg),
|
||||||
|
) -> Effect(msg) {
|
||||||
|
use dispatch, root <- effect.before_paint
|
||||||
|
use value, _unsubscribe <- emit_context_request(root, context, subscribe)
|
||||||
|
case decode.run(value, decoder) {
|
||||||
|
Ok(msg) -> {
|
||||||
|
dispatch(msg)
|
||||||
|
}
|
||||||
|
Error(_) -> Nil
|
||||||
|
}
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./context.ffi.mjs", "emitContextRequest")
|
||||||
|
fn emit_context_request(
|
||||||
|
root _root: Dynamic,
|
||||||
|
context _context: Dynamic,
|
||||||
|
subscribe _subscribe: Bool,
|
||||||
|
callback _callback: fn(Dynamic, Option(fn() -> Nil)) -> Nil,
|
||||||
|
) -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture "context-request" events querying a particular context type.
|
||||||
|
/// Intended to be used in a context provider, on a child element near the
|
||||||
|
/// component root.
|
||||||
|
pub fn on_context_request(
|
||||||
|
context match_context: Dynamic,
|
||||||
|
handler handler: fn(Dynamic, Bool) -> msg,
|
||||||
|
) -> Attribute(msg) {
|
||||||
|
event.advanced("context-request", {
|
||||||
|
use ev_context <- decode.field("context", decode.dynamic)
|
||||||
|
use subscribe <- decode.field("subscribe", decode.bool)
|
||||||
|
use callback <- decode.field("callback", decode.dynamic)
|
||||||
|
case ev_context == match_context {
|
||||||
|
True -> {
|
||||||
|
decode.success(event.handler(
|
||||||
|
handler(callback, subscribe),
|
||||||
|
False,
|
||||||
|
ev_context == match_context,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
False ->
|
||||||
|
// returning a DecodeError seems like the only way to not trigger a message dispatch
|
||||||
|
decode.failure(
|
||||||
|
event.handler(handler(callback, subscribe), False, False),
|
||||||
|
"Context",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effect for passing context value back to consumers who have requested it.
|
||||||
|
pub fn update_consumers(
|
||||||
|
callbacks callbacks: List(Dynamic),
|
||||||
|
value value: Dynamic,
|
||||||
|
) -> Effect(msg) {
|
||||||
|
use _dispatch <- effect.from
|
||||||
|
do_update_consumers(callbacks:, value:)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn do_update_consumers(
|
||||||
|
callbacks callbacks: List(Dynamic),
|
||||||
|
value value: Dynamic,
|
||||||
|
) -> Nil {
|
||||||
|
case callbacks {
|
||||||
|
[] -> Nil
|
||||||
|
[cb, ..rest] -> {
|
||||||
|
call_callback(callback: cb, value:)
|
||||||
|
do_update_consumers(rest, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./context.ffi.mjs", "callCallback")
|
||||||
|
fn call_callback(callback _callback: Dynamic, value _value: Dynamic) -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
66
webc/src/viewer_controller_component.gleam
Normal file
66
webc/src/viewer_controller_component.gleam
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import gleam/dynamic.{type Dynamic}
|
||||||
|
import lustre.{type App}
|
||||||
|
import lustre/component
|
||||||
|
import lustre/effect.{type Effect}
|
||||||
|
import lustre/element.{type Element}
|
||||||
|
import lustre/element/html
|
||||||
|
|
||||||
|
import context
|
||||||
|
|
||||||
|
pub const name: String = "viewer-controller"
|
||||||
|
|
||||||
|
pub fn component() -> App(Nil, Model, Msg) {
|
||||||
|
lustre.component(init, update, view, [
|
||||||
|
component.on_attribute_change("root-path", fn(value) {
|
||||||
|
ParentChangedRootPath(value) |> Ok
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Model {
|
||||||
|
Model(root_path: String, root_path_consumers: List(Dynamic))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(_) -> #(Model, Effect(Msg)) {
|
||||||
|
#(Model(root_path: "", root_path_consumers: []), effect.none())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Msg {
|
||||||
|
ParentChangedRootPath(String)
|
||||||
|
ChildRequestedRootPath(Dynamic, Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
ParentChangedRootPath(root_path) -> #(
|
||||||
|
Model(..model, root_path:),
|
||||||
|
context.update_consumers(
|
||||||
|
model.root_path_consumers,
|
||||||
|
dynamic.string(root_path),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ChildRequestedRootPath(callback, subscribe) -> #(
|
||||||
|
case subscribe {
|
||||||
|
True ->
|
||||||
|
Model(..model, root_path_consumers: [
|
||||||
|
callback,
|
||||||
|
..model.root_path_consumers
|
||||||
|
])
|
||||||
|
False -> model
|
||||||
|
},
|
||||||
|
context.update_consumers([callback], dynamic.string(model.root_path)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(_: Model) -> Element(Msg) {
|
||||||
|
html.div(
|
||||||
|
[
|
||||||
|
context.on_context_request(
|
||||||
|
dynamic.string("root_path"),
|
||||||
|
ChildRequestedRootPath,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[component.default_slot([], [])],
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue