editing with the hoverbar works pretty smoothly

This commit is contained in:
Brent Schroeter 2025-07-30 23:02:11 -07:00
parent 316a3d8414
commit aa8bf34642
15 changed files with 867 additions and 174 deletions

View file

@ -178,11 +178,7 @@ pub async fn lens_page(
let pkey_attrs = fetch_primary_keys_for_rel(lens.class_oid, &mut *client).await?;
const FRONTEND_ROW_LIMIT: i64 = 1000;
struct Row {
pkeys: HashMap<String, Encodable>,
data: PgRow,
}
let rows: Vec<Row> = query(&format!(
let rows: Vec<PgRow> = query(&format!(
"select {0} from {1}.{2} limit $1",
pkey_attrs
.iter()
@ -195,18 +191,16 @@ pub async fn lens_page(
))
.bind(FRONTEND_ROW_LIMIT)
.fetch_all(&mut *client)
.await?
.into_iter()
.await?;
let pkeys: Vec<HashMap<String, Encodable>> = rows
.iter()
.map(|row| {
let mut pkey_values: HashMap<String, Encodable> = HashMap::new();
for attr in pkey_attrs.clone() {
let field = Field::default_from_attr(&attr);
pkey_values.insert(field.name.clone(), field.get_value_encodable(&row).unwrap());
}
Row {
pkeys: pkey_values,
data: row,
pkey_values.insert(field.name.clone(), field.get_value_encodable(row).unwrap());
}
pkey_values
})
.collect();
@ -215,13 +209,15 @@ pub async fn lens_page(
struct ResponseTemplate {
fields: Vec<Field>,
all_columns: Vec<PgAttribute>,
rows: Vec<Row>,
rows: Vec<PgRow>,
pkeys: Vec<HashMap<String, Encodable>>,
settings: Settings,
}
Ok(Html(
ResponseTemplate {
all_columns: attrs,
fields,
pkeys,
rows,
settings,
}

View file

@ -2,7 +2,7 @@
{% block main %}
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
<viewer-controller root-path="{{ settings.root_path }}" n-rows="{{ rows.len() }}" n-columns="{{ fields.len() }}">
<viewer-controller root-path="{{ settings.root_path }}" pkeys="{{ pkeys | json }}" fields="{{ fields | json }}">
<table class="viewer">
<thead>
<tr>
@ -19,11 +19,11 @@
<tbody>
{% for (i, row) in rows.iter().enumerate() %}
{# TODO: store primary keys in a Vec separate from rows #}
<tr data-pkey="{{ row.pkeys | json }}">
<tr>
{% for (j, field) in fields.iter().enumerate() %}
{# Setting max-width is required for overflow to work properly. #}
<td style="width: {{ field.width_px }}px; max-width: {{ field.width_px }}px;">
{% match field.get_value_encodable(row.data) %}
{% match field.get_value_encodable(row) %}
{% when Ok with (encodable) %}
<{{ field.webc_tag() | safe }}
{% for (k, v) in field.webc_custom_attrs() %}
@ -31,7 +31,7 @@
{% endfor %}
row="{{ i }}"
column="{{ j }}"
value="{{ encodable.inner_as_value() | json }}"
value="{{ encodable | json }}"
class="cell"
>
{{ encodable.inner_as_value() | json }}

View file

@ -1,10 +1,35 @@
@use 'globals';
@use 'hoverbar';
.control-bar__input {
@include globals.reset-input;
.text-editor {
width: 100%;
height: 100%;
display: flex;
align-items: stretch;
&__input {
@include globals.reset-input;
padding: 0.5rem;
font-family: globals.$font-family-data;
flex: 1;
}
}
.toggle {
&__container {
display: flex;
align-items: center;
}
&__checkbox {
margin-right: 0.25rem;
}
&__label {
margin-right: 0.5rem;
&--disabled {
opacity: 0.5;
}
}
}

View file

@ -20,6 +20,8 @@ gleam_json = ">= 3.0.2 and < 4.0.0"
gleam_regexp = ">= 1.1.1 and < 2.0.0"
plinth = ">= 0.7.1 and < 1.0.0"
gleam_javascript = ">= 1.0.0 and < 2.0.0"
rsvp = ">= 1.1.2 and < 2.0.0"
gleam_http = ">= 4.1.1 and < 5.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

View file

@ -13,7 +13,8 @@ packages = [
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
{ name = "gleam_deque", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_deque", source = "hex", outer_checksum = "64D77068931338CF0D0CB5D37522C3E3CCA7CB7D6C5BACB41648B519CC0133C7" },
{ name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" },
{ name = "gleam_http", version = "4.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DB25DFC8530B64B77105405B80686541A0D96F7E2D83D807D6B2155FB9A8B1B8" },
{ name = "gleam_fetch", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript", "gleam_stdlib"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "2CBF9F2E1C71AEBBFB13A9D5720CD8DB4263EB02FE60C5A7A1C6E17B0151C20C" },
{ name = "gleam_http", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "DD0271B32C356FB684EC7E9F48B1E835D0480168848581F68983C0CC371405D4" },
{ name = "gleam_httpc", version = "4.1.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C670EBD46FC1472AD5F1F74F1D3938D1D0AC1C7531895ED1D4DDCB6F07279F43" },
{ name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" },
{ name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" },
@ -36,6 +37,7 @@ packages = [
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
{ name = "plinth", version = "0.7.1", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_json", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "63BB36AACCCCB82FBE46A862CF85CB88EBE4EF280ECDBAC4B6CB042340B9E1D8" },
{ name = "repeatedly", version = "2.1.2", build_tools = ["gleam"], requirements = [], otp_app = "repeatedly", source = "hex", outer_checksum = "93AE1938DDE0DC0F7034F32C1BF0D4E89ACEBA82198A1FE21F604E849DA5F589" },
{ name = "rsvp", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_fetch", "gleam_http", "gleam_httpc", "gleam_javascript", "gleam_json", "gleam_stdlib", "lustre"], otp_app = "rsvp", source = "hex", outer_checksum = "0C0732577712E7CB0E55F057637E62CD36F35306A5E830DC4874B83DA8CE4638" },
{ name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" },
{ name = "snag", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "snag", source = "hex", outer_checksum = "7E9F06390040EB5FAB392CE642771484136F2EC103A92AE11BA898C8167E6E17" },
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
@ -45,6 +47,7 @@ packages = [
]
[requirements]
gleam_http = { version = ">= 4.1.1 and < 5.0.0" }
gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" }
gleam_json = { version = ">= 3.0.2 and < 4.0.0" }
gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" }
@ -53,3 +56,4 @@ gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
lustre = { version = ">= 5.2.1 and < 6.0.0" }
lustre_dev_tools = { version = ">= 1.9.0 and < 2.0.0" }
plinth = { version = ">= 0.7.1 and < 1.0.0" }
rsvp = { version = ">= 1.1.2 and < 2.0.0" }

View file

@ -27,40 +27,25 @@ pub fn options_common(
component.on_attribute_change("selected", fn(value) {
ParentChangedSelected(value != "") |> CommonMsg |> map_msg |> Ok
}),
component.on_attribute_change("editing", fn(value) {
ParentChangedEditing(value != "") |> CommonMsg |> map_msg |> Ok
}),
]
}
pub type ModelCommon {
ModelCommon(
root_path: String,
row: Int,
column: Int,
selected: Bool,
editing: Bool,
)
ModelCommon(root_path: String, row: Int, column: Int, selected: Bool)
}
pub type Msg {
AncestorChangedRootPath(String)
ParentChangedEditing(Bool)
ParentChangedRow(Int)
ParentChangedColumn(Int)
ParentChangedSelected(Bool)
UserClickedCell
UserDoubleClickedCell
}
pub fn init(_) -> #(ModelCommon, Effect(CommonMsg)) {
#(
ModelCommon(
root_path: "",
selected: False,
editing: False,
row: -1,
column: -1,
),
ModelCommon(root_path: "", selected: False, row: -1, column: -1),
context.request_context(
context: dynamic.string("root_path"),
subscribe: False,
@ -90,10 +75,6 @@ pub fn update(
ModelCommon(..model, column:),
effect.none(),
)
ParentChangedEditing(editing) -> #(
ModelCommon(..model, editing:),
effect.none(),
)
ParentChangedSelected(selected) -> #(
ModelCommon(..model, selected:),
effect.none(),
@ -108,6 +89,16 @@ pub fn update(
]),
),
)
UserDoubleClickedCell -> #(
model,
event.emit(
"cell-dblclick",
json.object([
#("row", json.int(model.row)),
#("column", json.int(model.column)),
]),
),
)
}
}
@ -128,7 +119,11 @@ pub fn view(
True -> attr.class("cell__container--selected")
False -> attr.none()
},
event.on_click(CommonMsg(UserClickedCell) |> map_msg),
event.on_click(CommonMsg(UserClickedCell) |> map_msg()),
event.on(
"dblclick",
CommonMsg(UserDoubleClickedCell) |> map_msg() |> decode.success(),
),
],
[inner()],
),

View file

@ -1,6 +1,6 @@
import gleam/dynamic/decode
import gleam/json
import gleam/option.{type Option}
import gleam/option.{type Option, None}
import gleam/result
import lustre.{type App}
import lustre/attribute as attr
@ -10,13 +10,24 @@ import lustre/element.{type Element}
import lustre/element/html
import cell_common.{type CommonMsg, type ModelCommon}
import encodable
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
json.parse(
value,
encodable.decoder()
|> decode.map(fn(encodable) {
case encodable {
encodable.Text(content) -> ParentChangedValue(content)
_ -> ParentChangedValue(None)
}
}),
)
|> result.map_error(fn(_) { Nil })
}),
..cell_common.options_common(Common)
])
@ -37,7 +48,7 @@ fn init(_) -> #(Model, Effect(Msg)) {
pub type Msg {
Common(CommonMsg)
ParentChangedValue(String)
ParentChangedValue(Option(String))
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
@ -46,14 +57,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
let #(common, effect) = cell_common.update(model.common, sub_msg)
#(Model(..model, common:), effect)
}
ParentChangedValue(value) -> #(
Model(
..model,
value: json.parse(value, decode.optional(decode.string))
|> result.unwrap(option.None),
),
effect.none(),
)
ParentChangedValue(value) -> #(Model(..model, value:), effect.none())
}
}

View file

@ -1,6 +1,6 @@
import gleam/dynamic/decode
import gleam/json
import gleam/option.{type Option}
import gleam/option.{type Option, None}
import gleam/result
import lustre.{type App}
import lustre/attribute as attr
@ -10,13 +10,24 @@ import lustre/element.{type Element}
import lustre/element/html
import cell_common.{type CommonMsg, type ModelCommon}
import encodable
pub const name: String = "cell-uuid"
pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [
component.on_attribute_change("value", fn(value) {
ParentChangedValue(value) |> Ok
json.parse(
value,
encodable.decoder()
|> decode.map(fn(encodable) {
case encodable {
encodable.Uuid(content) -> ParentChangedValue(content)
_ -> ParentChangedValue(None)
}
}),
)
|> result.map_error(fn(_) { Nil })
}),
..cell_common.options_common(Common)
])
@ -37,7 +48,7 @@ fn init(_) -> #(Model, Effect(Msg)) {
pub type Msg {
Common(CommonMsg)
ParentChangedValue(String)
ParentChangedValue(Option(String))
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
@ -46,14 +57,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
let #(common, effect) = cell_common.update(model.common, sub_msg)
#(Model(..model, common:), effect)
}
ParentChangedValue(value) -> #(
Model(
..model,
value: json.parse(value, decode.optional(decode.string))
|> result.unwrap(option.None),
),
effect.none(),
)
ParentChangedValue(value) -> #(Model(..model, value:), effect.none())
}
}

57
webc/src/encodable.gleam Normal file
View file

@ -0,0 +1,57 @@
import gleam/dynamic/decode.{type Decoder}
import gleam/json.{type Json}
import gleam/option.{type Option, None, Some}
pub type Encodable {
Integer(Option(Int))
Text(Option(String))
Timestamptz(Option(String))
Uuid(Option(String))
}
pub fn decoder() -> Decoder(Encodable) {
use t <- decode.field("t", decode.string)
case t {
"Integer" -> {
use c <- decode.field("c", decode.optional(decode.int))
decode.success(Integer(c))
}
"Text" -> {
use c <- decode.field("c", decode.optional(decode.string))
decode.success(Text(c))
}
"Timestamptz" -> {
use c <- decode.field("c", decode.optional(decode.string))
decode.success(Timestamptz(c))
}
"Uuid" -> {
use c <- decode.field("c", decode.optional(decode.string))
decode.success(Uuid(c))
}
_ -> decode.failure(Text(Some("")), "Encodable")
}
}
pub fn to_json(value: Encodable) -> Json {
json.object([
#(
"t",
json.string(case value {
Integer(_) -> "Integer"
Text(_) -> "Text"
Timestamptz(_) -> "Timestamptz"
Uuid(_) -> "Uuid"
}),
),
#("c", case value {
Integer(Some(inner)) -> json.int(inner)
Integer(None) -> json.null()
Text(Some(inner)) -> json.string(inner)
Text(None) -> json.null()
Timestamptz(Some(inner)) -> json.string(inner)
Timestamptz(None) -> json.null()
Uuid(Some(inner)) -> json.string(inner)
Uuid(None) -> json.null()
}),
])
}

23
webc/src/field.gleam Normal file
View file

@ -0,0 +1,23 @@
import gleam/dynamic/decode
import gleam/option.{type Option}
import field_type.{type FieldType}
pub type Field {
Field(
id: String,
name: String,
label: Option(String),
field_type: FieldType,
width_px: Int,
)
}
pub fn decoder() -> decode.Decoder(Field) {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use label <- decode.field("label", decode.optional(decode.string))
use field_type <- decode.field("field_type", field_type.decoder())
use width_px <- decode.field("width_px", decode.int)
decode.success(Field(id:, name:, label:, field_type:, width_px:))
}

42
webc/src/field_type.gleam Normal file
View file

@ -0,0 +1,42 @@
import gleam/dynamic/decode
import gleam/json.{type Json}
pub type FieldType {
Integer
InterimUser
Text
Timestamp(format: String)
Uuid
Unknown
}
pub fn to_json(field_type: FieldType) -> Json {
case field_type {
Integer -> json.object([#("t", json.string("Integer"))])
InterimUser -> json.object([#("t", json.string("Interim_user"))])
Text -> json.object([#("t", json.string("Text"))])
Timestamp(format:) ->
json.object([
#("t", json.string("Timestamp")),
#("c", json.object([#("format", json.string(format))])),
])
Uuid -> json.object([#("t", json.string("Uuid"))])
Unknown -> json.object([#("t", json.string("Unknown"))])
}
}
pub fn decoder() -> decode.Decoder(FieldType) {
use variant <- decode.field("t", decode.string)
case variant {
"Integer" -> decode.success(Integer)
"InterimUser" -> decode.success(InterimUser)
"Text" -> decode.success(Text)
"Timestamp" -> {
use format <- decode.subfield(["c", "format"], decode.string)
decode.success(Timestamp(format:))
}
"Uuid" -> decode.success(Uuid)
"Unknown" -> decode.success(Unknown)
_ -> decode.failure(Unknown, "FieldType")
}
}

View file

@ -1,3 +1,18 @@
export function focusHoverbar() {
const hoverbar = document.querySelector("viewer-hoverbar");
hoverbar?.focus();
}
export function blurHoverbar() {
const hoverbar = document.querySelector("viewer-hoverbar");
hoverbar?.blur();
}
export function overwriteInHoverbar(value) {
const hoverbar = document.querySelector("viewer-hoverbar");
hoverbar?.overwrite(value);
}
export function clearSelectedAttrs() {
document.querySelectorAll(
"table.viewer > tbody > tr > td > [selected='true']",
@ -5,12 +20,29 @@ export function clearSelectedAttrs() {
.forEach((element) => element.setAttribute("selected", ""));
}
export function setSelectedAttr(row, column) {
const tr = document.querySelectorAll("table.viewer > tbody > tr")[row];
if (tr) {
const cell = [...tr.querySelectorAll(":scope > td > *")][column];
export function setCellAttr(row, column, key, value) {
const cell = queryCell(row, column);
if (cell) {
cell.setAttribute("selected", "true");
cell.setAttribute(key, value);
}
}
export function syncCellValueToHoverbar(row, column, fieldType) {
const cell = queryCell(row, column);
if (cell) {
const value = cell.getAttribute("value") ?? "null";
const hoverbar = document.querySelector("viewer-hoverbar");
if (hoverbar) {
hoverbar.setAttribute("value", value);
hoverbar.setAttribute("field-type", fieldType);
}
}
}
function queryCell(row, column) {
const tr = document.querySelectorAll("table.viewer > tbody > tr")[row];
if (tr) {
return [...tr.querySelectorAll(":scope > td > *")][column];
}
return undefined;
}

View file

@ -1,10 +1,15 @@
import gleam/dict.{type Dict}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/int
import gleam/http/response.{type Response}
import gleam/io
import gleam/json
import gleam/list
import gleam/option.{type Option, Some}
import gleam/option.{None, Some}
import gleam/pair
import gleam/regexp
import gleam/result
import gleam/set.{type Set}
import lustre.{type App}
import lustre/component
import lustre/effect.{type Effect}
@ -13,8 +18,12 @@ import lustre/element/html
import lustre/event
import plinth/browser/document
import plinth/browser/event as plinth_event
import rsvp
import context
import encodable.{type Encodable}
import field.{type Field}
import field_type.{type FieldType}
pub const name: String = "viewer-controller"
@ -23,11 +32,27 @@ pub fn component() -> App(Nil, Model, Msg) {
component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok
}),
component.on_attribute_change("n-rows", fn(value) {
int.parse(value) |> result.map(ParentChangedNRows)
component.on_attribute_change("pkeys", fn(value) {
value
|> json.parse(
decode.list(decode.dict(decode.string, encodable.decoder())),
)
|> result.map(fn(lst) {
list.zip(list.range(0, list.length(lst)), lst)
|> dict.from_list()
|> ParentChangedPkeys()
})
|> result.replace_error(Nil)
}),
component.on_attribute_change("n-columns", fn(value) {
int.parse(value) |> result.map(ParentChangedNColumns)
component.on_attribute_change("fields", fn(value) {
value
|> json.parse(decode.list(field.decoder()))
|> result.map(fn(lst) {
list.zip(list.range(0, list.length(lst)), lst)
|> dict.from_list()
|> ParentChangedFields()
})
|> result.replace_error(Nil)
}),
component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok
@ -35,15 +60,19 @@ pub fn component() -> App(Nil, Model, Msg) {
])
}
// -------- Model -------- //
pub type Model {
Model(
root_path: String,
root_path_consumers: List(Dynamic),
selected_row: Option(Int),
selected_column: Option(Int),
selections: List(#(Int, Int)),
editing: Bool,
n_rows: Int,
n_columns: Int,
pkeys: Dict(Int, Dict(String, Encodable)),
fields: Dict(Int, Field),
// Whether OS, ctrl, or alt keys are being held. If so, simultaneously
// pressing a typing key should not trigger an overwrite.
modifiers_held: Set(String),
)
}
@ -52,11 +81,11 @@ fn init(_) -> #(Model, Effect(Msg)) {
Model(
root_path: "",
root_path_consumers: [],
selected_row: option.None,
selected_column: option.None,
selections: [],
editing: False,
n_rows: -1,
n_columns: -1,
pkeys: dict.new(),
fields: dict.new(),
modifiers_held: set.new(),
),
effect.before_paint(fn(dispatch, _) -> Nil {
document.add_event_listener("keydown", fn(ev) -> Nil {
@ -64,6 +93,19 @@ fn init(_) -> #(Model, Effect(Msg)) {
case key {
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" ->
dispatch(UserPressedDirectionalKey(key))
"Enter" -> dispatch(UserPressedEnterKey)
"Meta" | "Alt" | "Control" -> dispatch(UserPressedModifierKey(key))
_ ->
case is_typing_key(key) {
True -> dispatch(UserPressedTypingKey(key))
False -> Nil
}
}
})
document.add_event_listener("keyup", fn(ev) -> Nil {
let key = plinth_event.key(ev)
case key {
"Meta" | "Alt" | "Control" -> dispatch(UserReleasedModifierKey(key))
_ -> Nil
}
})
@ -71,13 +113,30 @@ fn init(_) -> #(Model, Effect(Msg)) {
)
}
fn is_typing_key(key: String) -> Bool {
let assert Ok(re) =
regexp.from_string("^[a-zA-Z0-9!@#$%^&*._/?<>{}[\\]'\"~`-]$")
regexp.check(key, with: re)
}
// -------- Update -------- //
pub type Msg {
ParentChangedRootPath(String)
ParentChangedNRows(Int)
ParentChangedNColumns(Int)
ChildRequestedRootPath(Dynamic, Bool)
ParentChangedPkeys(Dict(Int, Dict(String, Encodable)))
ParentChangedFields(Dict(Int, Field))
ChildEmittedEditStartEvent
ChildEmittedEditUpdateEvent(original: Encodable, edited: Encodable)
ChildEmittedEditEndEvent(original: Encodable, edited: Encodable)
ChildRequestedRootPath(callback: Dynamic, subscribe: Bool)
ServerUpdateValuePost(Result(Response(String), rsvp.Error))
UserClickedCell(Int, Int)
UserDoubleClickedCell(Int, Int)
UserPressedDirectionalKey(String)
UserPressedEnterKey
UserPressedTypingKey(String)
UserPressedModifierKey(String)
UserReleasedModifierKey(String)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
@ -89,11 +148,22 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
dynamic.string(root_path),
),
)
ParentChangedNRows(n_rows) -> #(Model(..model, n_rows:), effect.none())
ParentChangedNColumns(n_columns) -> #(
Model(..model, n_columns:),
ParentChangedPkeys(pkeys) -> #(Model(..model, pkeys:), effect.none())
ParentChangedFields(fields) -> #(Model(..model, fields:), effect.none())
ChildEmittedEditStartEvent -> #(
Model(..model, editing: True),
effect.none(),
)
ChildEmittedEditUpdateEvent(_original, edited) -> {
case model.selections {
[coords] -> #(model, set_cell_value(coords, edited))
_ -> todo
}
}
ChildEmittedEditEndEvent(_original, edited) -> #(
model,
commit_change(model:, value: edited),
)
ChildRequestedRootPath(callback, subscribe) -> #(
case subscribe {
True ->
@ -105,21 +175,32 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
},
context.update_consumers([callback], dynamic.string(model.root_path)),
)
UserClickedCell(row, column) -> #(
Model(
..model,
selected_row: option.Some(row),
selected_column: option.Some(column),
),
move_selection(to_row: option.Some(row), to_column: Some(column)),
)
ServerUpdateValuePost(response) -> {
case response {
Ok(_) -> #(Model(..model, editing: False), blur_hoverbar())
Error(rsvp.HttpError(response)) -> {
io.println_error("HTTP error while updating value: " <> response.body)
#(model, effect.none())
}
Error(err) -> {
echo err
#(model, effect.none())
}
}
}
UserClickedCell(row, column) -> assign_selection(model:, to: #(row, column))
// TODO
UserDoubleClickedCell(_row, _column) -> #(model, case model.editing {
True -> effect.none()
False -> focus_hoverbar()
})
UserPressedDirectionalKey(key) -> {
case model.editing, model.selected_row, model.selected_column {
False, Some(selected_row), Some(selected_column) -> {
case model.editing, model.selections {
False, [#(selected_row, selected_col)] -> {
let first_row_selected = selected_row == 0
let last_row_selected = selected_row == model.n_rows - 1
let first_col_selected = selected_column == 0
let last_col_selected = selected_column == model.n_columns - 1
let last_row_selected = selected_row == dict.size(model.pkeys) - 1
let first_col_selected = selected_col == 0
let last_col_selected = selected_col == dict.size(model.fields) - 1
case
key,
first_row_selected,
@ -127,65 +208,203 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
first_col_selected,
last_col_selected
{
"ArrowLeft", _, _, False, _ -> #(
Model(..model, selected_column: option.Some(selected_column - 1)),
move_selection(
to_row: model.selected_row,
to_column: Some(selected_column - 1),
),
)
"ArrowRight", _, _, _, False -> #(
Model(..model, selected_column: option.Some(selected_column + 1)),
move_selection(
to_row: model.selected_row,
to_column: Some(selected_column + 1),
),
)
"ArrowUp", False, _, _, _ -> #(
Model(..model, selected_row: option.Some(selected_row - 1)),
move_selection(
to_row: Some(selected_row - 1),
to_column: model.selected_column,
),
)
"ArrowDown", _, False, _, _ -> #(
Model(..model, selected_row: option.Some(selected_row + 1)),
move_selection(
to_row: Some(selected_row + 1),
to_column: model.selected_column,
),
)
"ArrowLeft", _, _, False, _ ->
assign_selection(model:, to: #(selected_row, selected_col - 1))
"ArrowRight", _, _, _, False ->
assign_selection(model:, to: #(selected_row, selected_col + 1))
"ArrowUp", False, _, _, _ ->
assign_selection(model:, to: #(selected_row - 1, selected_col))
"ArrowDown", _, False, _, _ ->
assign_selection(model:, to: #(selected_row + 1, selected_col))
_, _, _, _, _ -> #(model, effect.none())
}
}
False, [] ->
case dict.size(model.pkeys) > 0 && dict.size(model.fields) > 0 {
True -> assign_selection(model:, to: #(0, 0))
False -> #(model, effect.none())
}
_, _ -> #(model, effect.none())
}
}
UserPressedEnterKey -> #(model, case model.editing {
True -> effect.none()
False -> focus_hoverbar()
})
UserPressedTypingKey(key) ->
case model.editing, model.selections, set.is_empty(model.modifiers_held) {
False, [_], True -> #(model, overwrite_in_hoverbar(value: key))
_, _, _ -> #(model, effect.none())
}
}
UserPressedModifierKey(key) -> #(
Model(..model, modifiers_held: model.modifiers_held |> set.insert(key)),
effect.none(),
)
UserReleasedModifierKey(key) -> #(
Model(..model, modifiers_held: model.modifiers_held |> set.delete(key)),
effect.none(),
)
}
}
fn move_selection(
to_row to_row: Option(Int),
to_column to_column: Option(Int),
) -> Effect(msg) {
use _dispatch, _root <- effect.before_paint()
do_clear_selected_attrs()
case to_row, to_column {
option.Some(row), option.Some(column) -> do_set_selected_attr(row:, column:)
_, _ -> Nil
fn overwrite_in_hoverbar(value value: String) -> Effect(Msg) {
use _, _ <- effect.before_paint()
do_overwrite_in_hoverbar(value)
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "overwriteInHoverbar")
fn do_overwrite_in_hoverbar(value _value: String) -> Nil {
Nil
}
fn commit_change(model model: Model, value value: Encodable) -> Effect(Msg) {
case model.selections {
[#(row, col)] ->
rsvp.post(
"./update-value",
json.object([
#(
"column",
model.fields
|> dict.get(col)
|> result.map(fn(field) { field.name })
|> result.unwrap("")
|> json.string(),
),
#(
"pkeys",
model.pkeys
|> dict.get(row)
|> result.unwrap(dict.new())
|> json.dict(fn(x) { x }, encodable.to_json),
),
#("value", encodable.to_json(value)),
]),
rsvp.expect_ok_response(ServerUpdateValuePost),
)
_ -> todo
}
}
/// Updater for when selection is being assigned to a single cell.
fn assign_selection(
model model: Model,
to coords: #(Int, Int),
) -> #(Model, Effect(Msg)) {
// Multiple sets of conditions fall back to the "selection actually changed"
// scenario, so this is a little awkward to write without a return keyword.
let early_return = case model.selections {
[prev_coords] ->
case prev_coords == coords {
True -> Some(#(model, effect.none()))
False -> None
}
_ -> None
}
case early_return {
Some(value) -> value
None -> #(
Model(..model, selections: [coords]),
effect.batch([
sync_cell_value_to_hoverbar(
coords,
model.fields
|> dict.get(pair.second(coords))
|> result.map(fn(f) { f.field_type })
|> result.unwrap(field_type.Unknown),
),
change_selected_attrs([coords]),
]),
)
}
}
// -------- Effects -------- //
fn focus_hoverbar() -> Effect(Msg) {
use _dispatch, _root <- effect.before_paint()
do_focus_hoverbar()
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "focusHoverbar")
fn do_focus_hoverbar() -> Nil {
Nil
}
fn blur_hoverbar() -> Effect(Msg) {
use _dispatch, _root <- effect.before_paint()
do_blur_hoverbar()
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "blurHoverbar")
fn do_blur_hoverbar() -> Nil {
Nil
}
/// Effect that changes [selected=] attributes on assigned children. Should only
/// be called when the new selection is different than the previous one (that
/// is, not when selection is "moving" from a cell to the same cell).
fn change_selected_attrs(to_coords coords: List(#(Int, Int))) -> Effect(msg) {
use _, _ <- effect.before_paint()
do_clear_selected_attrs()
coords
|> list.map(fn(coords) {
let #(row, column) = coords
do_set_cell_attr(row:, column:, key: "selected", value: "true")
})
Nil
}
fn set_cell_value(coords coords: #(Int, Int), value value: Encodable) {
use _, _ <- effect.before_paint()
let #(row, col) = coords
do_set_cell_attr(
row,
col,
"value",
value |> encodable.to_json() |> json.to_string(),
)
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "clearSelectedAttrs")
fn do_clear_selected_attrs() -> Nil {
Nil
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "setSelectedAttr")
fn do_set_selected_attr(row _row: Int, column _column: Int) -> Nil {
@external(javascript, "./viewer_controller_component.ffi.mjs", "setCellAttr")
fn do_set_cell_attr(
row _row: Int,
column _column: Int,
key _key: String,
value _value: String,
) -> Nil {
Nil
}
fn sync_cell_value_to_hoverbar(
coords coords: #(Int, Int),
field_type f_type: FieldType,
) -> Effect(Msg) {
use _, _root <- effect.before_paint()
let #(row, column) = coords
do_sync_cell_value_to_hoverbar(
row:,
column:,
field_type: f_type |> field_type.to_json() |> json.to_string(),
)
}
@external(javascript, "./viewer_controller_component.ffi.mjs", "syncCellValueToHoverbar")
fn do_sync_cell_value_to_hoverbar(
row _row: Int,
column _column: Int,
field_type _field_type: String,
) -> Nil {
Nil
}
// -------- View -------- //
fn view(_: Model) -> Element(Msg) {
html.div(
[
@ -201,6 +420,31 @@ fn view(_: Model) -> Element(Msg) {
})
decode.success(msg)
}),
event.on("cell-dblclick", {
use msg <- decode.field("detail", {
use row <- decode.field("row", decode.int)
use column <- decode.field("column", decode.int)
decode.success(UserDoubleClickedCell(row, column))
})
decode.success(msg)
}),
event.on("edit-start", decode.success(ChildEmittedEditStartEvent)),
event.on("edit-update", {
use original <- decode.subfield(
["detail", "original"],
encodable.decoder(),
)
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
decode.success(ChildEmittedEditUpdateEvent(original:, edited:))
}),
event.on("edit-end", {
use original <- decode.subfield(
["detail", "original"],
encodable.decoder(),
)
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
decode.success(ChildEmittedEditEndEvent(original:, edited:))
}),
],
[component.default_slot([], [])],
)

View file

@ -0,0 +1,19 @@
export function registerComponentMethod(root, name, callback) {
root.host[name] = callback;
}
export function focusAtEnd(root) {
const input = root.querySelector('[tabindex="0"]');
if (input) {
const tmp = input.value;
input.value = "";
input.focus();
input.value = tmp;
}
}
export function blurAll(root) {
root.querySelectorAll("input").forEach((element) => {
element.blur();
});
}

View file

@ -1,11 +1,20 @@
import gleam/option.{None, Some}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/int
import gleam/io
import gleam/json
import gleam/option.{type Option, None, Some}
import gleam/result
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 lustre/event
import encodable.{type Encodable}
import field_type.{type FieldType}
import hoverbar
pub const name: String = "viewer-hoverbar"
@ -16,10 +25,14 @@ pub fn component() -> App(Nil, Model, Msg) {
ParentChangedRootPath(value) |> Ok
}),
component.on_attribute_change("field-type", fn(value) {
ParentChangedFieldType(value) |> Ok
json.parse(value, field_type.decoder())
|> result.map(ParentChangedFieldType)
|> result.replace_error(Nil)
}),
component.on_attribute_change("value", fn(value) {
ParentChangedValue(value) |> Ok
json.parse(value, decode.optional(encodable.decoder()))
|> result.map(ParentChangedValue)
|> result.replace_error(Nil)
}),
])
}
@ -28,29 +41,100 @@ pub type Model {
Model(
hoverbar_model: hoverbar.Model,
root_path: String,
field_type: String,
value: String,
field_type: FieldType,
value: Option(Encodable),
edit_state: EditState,
editing: Bool,
)
}
pub type EditState {
Text(value: Option(String))
Inactive
}
fn to_edit_state(value: Encodable, _: FieldType) -> EditState {
case value {
encodable.Integer(Some(value)) -> Text(Some(value |> int.to_string))
encodable.Integer(None) -> Text(None)
encodable.Text(Some(value)) -> Text(Some(value))
encodable.Text(None) -> Text(None)
encodable.Timestamptz(Some(value)) -> Text(Some(value))
encodable.Timestamptz(None) -> Text(None)
encodable.Uuid(Some(value)) -> Text(Some(value))
encodable.Uuid(None) -> Text(None)
}
}
fn from_edit_state(
edit_state: EditState,
f_type: FieldType,
) -> Result(Encodable, Nil) {
case f_type, edit_state {
field_type.Text, Text(value) -> Ok(encodable.Text(value))
field_type.Integer, Text(Some(value)) ->
value |> int.parse |> result.map(Some) |> result.map(encodable.Integer)
field_type.Integer, Text(None) -> Ok(encodable.Integer(None))
field_type.InterimUser, _ -> todo
// TODO validate uuid format
field_type.Uuid, Text(value) -> Ok(encodable.Uuid(value))
field_type.Timestamp(_format), _ -> todo
field_type.Unknown, _ -> Error(Nil)
_, Inactive -> Error(Nil)
}
}
fn init(_) -> #(Model, Effect(Msg)) {
let #(hoverbar_model, hoverbar_effect) = hoverbar.init(Nil)
#(
Model(
hoverbar_model: hoverbar.Model(..hoverbar_model, tab_key: "editor"),
root_path: "",
field_type: "",
value: "",
field_type: field_type.Unknown,
value: None,
edit_state: Inactive,
editing: False,
),
effect.batch([
hoverbar_effect |> effect.map(HoverbarMsg),
register_component_methods(),
]),
)
}
fn register_component_methods() -> Effect(Msg) {
use dispatch, root <- effect.before_paint()
register_component_method(root, "focus", fn() {
dispatch(ParentRequestedFocus)
})
register_component_method(root, "blur", fn() { dispatch(ParentRequestedBlur) })
register_component_method(root, "overwrite", fn(value: String) {
dispatch(ParentRequestedOverwrite(value))
})
}
@external(javascript, "./viewer_hoverbar_component.ffi.mjs", "registerComponentMethod")
fn register_component_method(
on _root: Dynamic,
name _name: String,
callback _callback: cb,
) -> Nil {
Nil
}
pub type Msg {
HoverbarMsg(hoverbar.Msg)
ComponentCycledInputValue
ParentChangedRootPath(String)
ParentChangedFieldType(String)
ParentChangedValue(String)
ParentChangedFieldType(FieldType)
ParentChangedValue(Option(Encodable))
ParentRequestedFocus
ParentRequestedBlur
ParentRequestedOverwrite(String)
UserBlurredInput
UserChangedInput(String)
UserFocusedInput
UserPressedKey(String)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
@ -63,16 +147,141 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
hoverbar_effect |> effect.map(HoverbarMsg),
)
}
ComponentCycledInputValue -> #(
Model(..model, editing: True),
emit_edit_start_event(),
)
ParentChangedRootPath(root_path) -> #(
Model(..model, root_path:),
effect.none(),
)
ParentChangedFieldType(field_type) -> #(
Model(..model, field_type:),
ParentChangedFieldType(f_type) -> {
#(Model(..model, field_type: f_type), effect.none())
}
ParentChangedValue(value) -> #(
Model(
..model,
value:,
edit_state: value
|> option.map(to_edit_state(_, model.field_type))
|> option.unwrap(Text(None)),
),
effect.none(),
)
ParentChangedValue(value) -> #(Model(..model, value:), effect.none())
ParentRequestedFocus -> {
#(model, focus_at_end())
}
ParentRequestedBlur -> {
#(model, blur_all())
}
ParentRequestedOverwrite(value) -> {
case model.edit_state {
Text(_) -> {
let new_model = Model(..model, edit_state: Text(Some(value)))
#(
new_model,
effect.batch([focus_at_end(), try_emit_edit_update_event(new_model)]),
)
}
_ -> #(model, effect.none())
}
}
// Place responsibility for committing the value on the viewer-controller.
// If the blur was due to the user clicking on a different cell, for
// example, the controller may wish to prevent the selection change if the
// edited value cannot be committed.
UserBlurredInput -> try_emit_edit_end_event(model)
UserChangedInput(value) -> {
case model.editing {
True -> {
let new_model = Model(..model, edit_state: Text(Some(value)))
#(new_model, try_emit_edit_update_event(new_model))
}
False -> #(model, effect.none())
}
}
UserFocusedInput -> #(
Model(..model, editing: True),
emit_edit_start_event(),
)
UserPressedKey(key) ->
case key {
"Enter" -> try_emit_edit_end_event(model)
"Escape" -> cancel_edit(model)
_ -> #(model, effect.none())
}
}
}
/// Cycle the value of the input element so that the cursor is placed at the end.
fn focus_at_end() -> Effect(Msg) {
use _dispatch, root <- effect.before_paint()
do_focus_at_end(in: root)
}
@external(javascript, "./viewer_hoverbar_component.ffi.mjs", "focusAtEnd")
fn do_focus_at_end(in _root: Dynamic) -> Nil {
Nil
}
fn blur_all() -> Effect(Msg) {
use _dispatch, root <- effect.before_paint()
do_blur_all(root)
}
@external(javascript, "./viewer_hoverbar_component.ffi.mjs", "blurAll")
fn do_blur_all(in _root: Dynamic) -> Nil {
Nil
}
fn emit_edit_start_event() -> Effect(Msg) {
event.emit("edit-start", json.null())
}
fn try_emit_edit_update_event(model: Model) -> Effect(Msg) {
case model.value, from_edit_state(model.edit_state, model.field_type) {
Some(original_value), Ok(edited_value) -> {
event.emit(
"edit-update",
json.object([
#("original", original_value |> encodable.to_json),
#("edited", edited_value |> encodable.to_json),
]),
)
}
_, _ -> effect.none()
}
}
fn try_emit_edit_end_event(model: Model) -> #(Model, Effect(Msg)) {
case model.value, from_edit_state(model.edit_state, model.field_type) {
Some(original_value), Ok(edited_value) -> #(
Model(..model, editing: False),
event.emit(
"edit-end",
json.object([
#("original", original_value |> encodable.to_json),
#("edited", edited_value |> encodable.to_json),
]),
),
)
// TODO flag error to user
_, _ -> #(model, effect.none())
}
}
fn cancel_edit(model: Model) -> #(Model, Effect(Msg)) {
let new_model =
Model(
..model,
edit_state: model.value
|> option.map(to_edit_state(_, model.field_type))
|> option.unwrap(Inactive),
)
#(
new_model,
effect.batch([try_emit_edit_update_event(new_model), blur_all()]),
)
}
fn view(model: Model) -> Element(Msg) {
@ -97,16 +306,53 @@ fn view(model: Model) -> Element(Msg) {
_ -> element.none()
}
},
control_panel: fn(tab_key) {
case tab_key {
"editor" -> None
_ -> None
}
},
control_panel: fn(_) { None },
),
])
}
fn editor_control_bar(_model: Model) -> Element(Msg) {
html.input([attr.type_("text"), attr.class("control-bar__input")])
fn editor_control_bar(model: Model) -> Element(Msg) {
case model.edit_state {
Text(value) ->
html.div([attr.class("text-editor")], [
html.input([
attr.type_("text"),
attr.class("text-editor__input"),
attr.value(value |> option.unwrap("")),
attr.placeholder(case value |> option.is_none() {
True -> "Null"
False -> "Empty string"
}),
attr.tabindex(0),
event.on_focus(UserFocusedInput),
event.on_blur(UserBlurredInput),
event.on_input(UserChangedInput),
event.on_keydown(UserPressedKey),
]),
html.div([attr.class("toggle__container")], [
html.input([
attr.type_("checkbox"),
attr.id("null-checkbox"),
attr.class("toggle__checkbox"),
case value {
Some("") | None -> attr.none()
_ -> attr.disabled(True)
},
attr.checked(value |> option.is_none()),
]),
html.label(
[
attr.class("toggle__label"),
case value {
Some("") | None -> attr.none()
_ -> attr.class("toggle__label--disabled")
},
attr.for("null-checkbox"),
],
[html.text("Null")],
),
]),
])
Inactive -> html.div([attr.class("editor")], [])
}
}