diff --git a/interim-server/src/routes/lenses.rs b/interim-server/src/routes/lenses.rs index 5d64894..e695b2f 100644 --- a/interim-server/src/routes/lenses.rs +++ b/interim-server/src/routes/lenses.rs @@ -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, - data: PgRow, - } - let rows: Vec = query(&format!( + let rows: Vec = 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 = 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> = rows + .iter() + .map(|row| { + let mut pkey_values: HashMap = 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, all_columns: Vec, - rows: Vec, + rows: Vec, + pkeys: Vec>, settings: Settings, } Ok(Html( ResponseTemplate { all_columns: attrs, fields, + pkeys, rows, settings, } diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html index d0947b0..80e1b0f 100644 --- a/interim-server/templates/lens.html +++ b/interim-server/templates/lens.html @@ -2,7 +2,7 @@ {% block main %} - + @@ -19,11 +19,11 @@ {% for (i, row) in rows.iter().enumerate() %} {# TODO: store primary keys in a Vec separate from rows #} - + {% for (j, field) in fields.iter().enumerate() %} {# Setting max-width is required for overflow to work properly. #}
- {% 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 }} diff --git a/sass/viewer_hoverbar.scss b/sass/viewer_hoverbar.scss index 12578eb..e80bada 100644 --- a/sass/viewer_hoverbar.scss +++ b/sass/viewer_hoverbar.scss @@ -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; + } + } } diff --git a/webc/gleam.toml b/webc/gleam.toml index 01b5973..186c7e9 100644 --- a/webc/gleam.toml +++ b/webc/gleam.toml @@ -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" diff --git a/webc/manifest.toml b/webc/manifest.toml index c42a809..747ee94 100644 --- a/webc/manifest.toml +++ b/webc/manifest.toml @@ -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" } diff --git a/webc/src/cell_common.gleam b/webc/src/cell_common.gleam index fae5074..3109374 100644 --- a/webc/src/cell_common.gleam +++ b/webc/src/cell_common.gleam @@ -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()], ), diff --git a/webc/src/cell_text_component.gleam b/webc/src/cell_text_component.gleam index 0a133ac..c5aa420 100644 --- a/webc/src/cell_text_component.gleam +++ b/webc/src/cell_text_component.gleam @@ -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()) } } diff --git a/webc/src/cell_uuid_component.gleam b/webc/src/cell_uuid_component.gleam index d7a9197..2980c8d 100644 --- a/webc/src/cell_uuid_component.gleam +++ b/webc/src/cell_uuid_component.gleam @@ -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()) } } diff --git a/webc/src/encodable.gleam b/webc/src/encodable.gleam new file mode 100644 index 0000000..752eba4 --- /dev/null +++ b/webc/src/encodable.gleam @@ -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() + }), + ]) +} diff --git a/webc/src/field.gleam b/webc/src/field.gleam new file mode 100644 index 0000000..93c38d9 --- /dev/null +++ b/webc/src/field.gleam @@ -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:)) +} diff --git a/webc/src/field_type.gleam b/webc/src/field_type.gleam new file mode 100644 index 0000000..1a1ee82 --- /dev/null +++ b/webc/src/field_type.gleam @@ -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") + } +} diff --git a/webc/src/viewer_controller_component.ffi.mjs b/webc/src/viewer_controller_component.ffi.mjs index 3c06064..9d222dc 100644 --- a/webc/src/viewer_controller_component.ffi.mjs +++ b/webc/src/viewer_controller_component.ffi.mjs @@ -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; +} diff --git a/webc/src/viewer_controller_component.gleam b/webc/src/viewer_controller_component.gleam index e57d983..afd2449 100644 --- a/webc/src/viewer_controller_component.gleam +++ b/webc/src/viewer_controller_component.gleam @@ -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([], [])], ) diff --git a/webc/src/viewer_hoverbar_component.ffi.mjs b/webc/src/viewer_hoverbar_component.ffi.mjs new file mode 100644 index 0000000..56df304 --- /dev/null +++ b/webc/src/viewer_hoverbar_component.ffi.mjs @@ -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(); + }); +} diff --git a/webc/src/viewer_hoverbar_component.gleam b/webc/src/viewer_hoverbar_component.gleam index 0c31306..66d34f9 100644 --- a/webc/src/viewer_hoverbar_component.gleam +++ b/webc/src/viewer_hoverbar_component.gleam @@ -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")], []) + } }