diff --git a/glm/src/glm.gleam.bak b/glm/src/glm.gleam.bak deleted file mode 100644 index e7a0ddc..0000000 --- a/glm/src/glm.gleam.bak +++ /dev/null @@ -1,6 +0,0 @@ -import field_adder - -pub fn main() -> Nil { - let assert Ok(_) = field_adder.register() - Nil -} diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html index b783231..8637aa7 100644 --- a/interim-server/templates/lens.html +++ b/interim-server/templates/lens.html @@ -1,49 +1,49 @@ {% extends "base.html" %} {% block main %} - - - - - + + + - - - - {% for field in fields %} - + +
-
{{ field.label.clone().unwrap_or(field.name.clone()) }}
-
+ + + {% for field in fields %} + + {% endfor %} + + + + + {% for row in rows %} + + {% for field in fields %} + + {% endfor %} + {% endfor %} - - - - - {% for row in rows %} - - {% for field in fields %} - - {% endfor %} - - {% endfor %} - -
+
{{ field.label.clone().unwrap_or(field.name.clone()) }}
+
+ +
+ {% match field.get_value_encodable(row.data) %} + {% when Ok with (encodable) %} + <{{ field.webc_tag() | safe }} + {% for (k, v) in field.webc_custom_attrs() %} + {{ k }}="{{ v }}" + {% endfor %} + column="{{ field.name }}" + value="{{ encodable | json }}" + class="cell" + > + {{ field.render(encodable) | safe }} + {{ err }} + {% endmatch %} +
- -
- {% match field.get_value_encodable(row.data) %} - {% when Ok with (encodable) %} - <{{ field.webc_tag() | safe }} - {% for (k, v) in field.webc_custom_attrs() %} - {{ k }}="{{ v }}" - {% endfor %} - column="{{ field.name }}" - value="{{ encodable | json }}" - class="cell" - > - {{ field.render(encodable) | safe }} - {{ err }} - {% endmatch %} -
+ + + {% endblock %} diff --git a/mise.toml b/mise.toml index 5beb385..b40fd53 100644 --- a/mise.toml +++ b/mise.toml @@ -16,17 +16,12 @@ run = "cargo run serve" description = "Run the server. For development: `mise watch --restart serve`." sources = ["**/*.rs", "**/*.html"] -[tasks.vite] -run = "deno task build" -dir = "./components" -sources = ["**/*.ts"] +[tasks.build-js] +run = "sh build.sh" +dir = "./webc" +sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"] -[tasks.lustre] -run = "gleam run -m lustre/dev build component field_adder --outdir=../glm_dist" -dir = "./glm" -sources = ["**/*.gleam"] - -[tasks.sass] +[tasks.build-css] run = "sass sass/:css_dist/" sources = ["sass/**/*.scss"] diff --git a/glm/.gitignore b/webc/.gitignore similarity index 100% rename from glm/.gitignore rename to webc/.gitignore diff --git a/glm/README.md b/webc/README.md similarity index 100% rename from glm/README.md rename to webc/README.md diff --git a/webc/build.sh b/webc/build.sh new file mode 100644 index 0000000..8984040 --- /dev/null +++ b/webc/build.sh @@ -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 diff --git a/glm/gleam.toml b/webc/gleam.toml similarity index 98% rename from glm/gleam.toml rename to webc/gleam.toml index c25b6f9..b9673f9 100644 --- a/glm/gleam.toml +++ b/webc/gleam.toml @@ -1,4 +1,4 @@ -name = "glm" +name = "webc" version = "1.0.0" target = "javascript" diff --git a/glm/manifest.toml b/webc/manifest.toml similarity index 100% rename from glm/manifest.toml rename to webc/manifest.toml diff --git a/webc/src/cell_text_component.gleam b/webc/src/cell_text_component.gleam new file mode 100644 index 0000000..d4b9b61 --- /dev/null +++ b/webc/src/cell_text_component.gleam @@ -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)]), + ]) +} diff --git a/webc/src/context.ffi.mjs b/webc/src/context.ffi.mjs new file mode 100644 index 0000000..cdb8712 --- /dev/null +++ b/webc/src/context.ffi.mjs @@ -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); +} diff --git a/webc/src/context.gleam b/webc/src/context.gleam new file mode 100644 index 0000000..15468c6 --- /dev/null +++ b/webc/src/context.gleam @@ -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 +} diff --git a/glm/src/field_adder.ffi.mjs b/webc/src/field_adder.ffi.mjs similarity index 100% rename from glm/src/field_adder.ffi.mjs rename to webc/src/field_adder.ffi.mjs diff --git a/glm/src/field_adder.gleam b/webc/src/field_adder_component.gleam similarity index 100% rename from glm/src/field_adder.gleam rename to webc/src/field_adder_component.gleam diff --git a/webc/src/viewer_controller_component.gleam b/webc/src/viewer_controller_component.gleam new file mode 100644 index 0000000..6ef6df8 --- /dev/null +++ b/webc/src/viewer_controller_component.gleam @@ -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([], [])], + ) +}