editing with the hoverbar works pretty smoothly
This commit is contained in:
parent
316a3d8414
commit
aa8bf34642
15 changed files with 867 additions and 174 deletions
|
|
@ -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,33 +191,33 @@ pub async fn lens_page(
|
|||
))
|
||||
.bind(FRONTEND_ROW_LIMIT)
|
||||
.fetch_all(&mut *client)
|
||||
.await?
|
||||
.into_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,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
.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());
|
||||
}
|
||||
pkey_values
|
||||
})
|
||||
.collect();
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "lens.html")]
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,35 @@
|
|||
@use 'globals';
|
||||
@use 'hoverbar';
|
||||
|
||||
.control-bar__input {
|
||||
@include globals.reset-input;
|
||||
.text-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
font-family: globals.$font-family-data;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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()],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
57
webc/src/encodable.gleam
Normal 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
23
webc/src/field.gleam
Normal 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
42
webc/src/field_type.gleam
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
if (cell) {
|
||||
cell.setAttribute("selected", "true");
|
||||
export function setCellAttr(row, column, key, value) {
|
||||
const cell = queryCell(row, column);
|
||||
if (cell) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
_, _, _ -> #(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([], [])],
|
||||
)
|
||||
|
|
|
|||
19
webc/src/viewer_hoverbar_component.ffi.mjs
Normal file
19
webc/src/viewer_hoverbar_component.ffi.mjs
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
hoverbar_effect |> effect.map(HoverbarMsg),
|
||||
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,18 +147,143 @@ 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) {
|
||||
element.fragment([
|
||||
html.link([
|
||||
|
|
@ -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")], [])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue