repair datum editor

This commit is contained in:
Brent Schroeter 2025-10-16 06:40:11 +00:00
parent 395a547b94
commit 5a24454787
9 changed files with 417 additions and 354 deletions

View file

@ -72,7 +72,7 @@ pub(super) async fn get(
}; };
let mut sql_raw = format!( let mut sql_raw = format!(
"select {0} from {1}.{2}", "select {0} from {1}.{2} order by _id",
pkey_attrs pkey_attrs
.iter() .iter()
.chain(attrs.iter()) .chain(attrs.iter())

View file

@ -21,7 +21,7 @@ mod update_form_transitions_handler;
mod update_portal_name_handler; mod update_portal_name_handler;
mod update_prompts_handler; mod update_prompts_handler;
mod update_rel_name_handler; mod update_rel_name_handler;
mod update_value_handler; mod update_values_handler;
pub(super) fn new_router() -> Router<App> { pub(super) fn new_router() -> Router<App> {
Router::<App>::new() Router::<App>::new()
@ -46,8 +46,8 @@ pub(super) fn new_router() -> Router<App> {
) )
.route("/p/{portal_id}/insert", post(insert_handler::post)) .route("/p/{portal_id}/insert", post(insert_handler::post))
.route( .route(
"/p/{portal_id}/update-value", "/p/{portal_id}/update-values",
post(update_value_handler::post), post(update_values_handler::post),
) )
.route("/p/{portal_id}/set-filter", post(set_filter_handler::post)) .route("/p/{portal_id}/set-filter", post(set_filter_handler::post))
.route_with_tsr("/p/{portal_id}/form/", get(form_handler::get)) .route_with_tsr("/p/{portal_id}/form/", get(form_handler::get))

View file

@ -5,9 +5,6 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
response::{IntoResponse as _, Response}, response::{IntoResponse as _, Response},
}; };
// [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use interim_models::{ use interim_models::{
datum::Datum, datum::Datum,
portal::Portal, portal::Portal,
@ -17,7 +14,7 @@ use interim_models::{
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass}; use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use sqlx::{postgres::types::Oid, query}; use sqlx::{Acquire as _, postgres::types::Oid, query};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@ -36,12 +33,17 @@ pub(super) struct PathParams {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct FormBody { pub(super) struct FormBody {
cells: Vec<CellInfo>,
}
#[derive(Debug, Deserialize)]
pub(super) struct CellInfo {
column: String, column: String,
pkeys: HashMap<String, Datum>, pkey: HashMap<String, Datum>,
value: Datum, value: Datum,
} }
/// HTTP POST handler for updating a single value in a backing Postgres table. /// HTTP POST handler for updating cell values in a backing Postgres table.
/// ///
/// This handler expects 3 path parameters with the structure described by /// This handler expects 3 path parameters with the structure described by
/// [`PathParams`]. /// [`PathParams`].
@ -55,7 +57,7 @@ pub(super) async fn post(
rel_oid, rel_oid,
workspace_id, workspace_id,
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Json(form): Json<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// Check workspace authorization. // Check workspace authorization.
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
@ -70,7 +72,7 @@ pub(super) async fn post(
// permission to access/alter both as needed. // permission to access/alter both as needed.
// Prevent users from modifying Phonograph metadata columns. // Prevent users from modifying Phonograph metadata columns.
if form.column.starts_with('_') { if form.cells.iter().any(|cell| cell.column.starts_with('_')) {
return Err(forbidden!("access denied to update system metadata column")); return Err(forbidden!("access denied to update system metadata column"));
} }
@ -91,19 +93,24 @@ pub(super) async fn post(
.fetch_all(&mut workspace_client) .fetch_all(&mut workspace_client)
.await?; .await?;
// TODO: simplify pkey management let conn = workspace_client.get_conn();
form.pkeys let mut txn = conn.begin().await?;
.get(&pkey_attrs.first().unwrap().attname) for cell in form.cells {
.unwrap() // TODO: simplify pkey management
.clone() cell.pkey
.bind_onto(form.value.bind_onto(query(&format!( .get(&pkey_attrs.first().unwrap().attname)
"update {ident} set {value_col} = $1 where {pkey_col} = $2", .unwrap()
ident = rel.get_identifier(), .clone()
value_col = escape_identifier(&form.column), .bind_onto(cell.value.bind_onto(query(&format!(
pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname), "update {ident} set {value_col} = $1 where {pkey_col} = $2",
)))) ident = rel.get_identifier(),
.execute(workspace_client.get_conn()) value_col = escape_identifier(&cell.column),
.await?; pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname),
))))
.execute(&mut *txn)
.await?;
}
txn.commit().await?;
Ok(Json(json!({ "ok": true })).into_response()) Ok(Json(json!({ "ok": true })).into_response())
} }

View file

@ -26,6 +26,5 @@
</main> </main>
</div> </div>
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/field-adder.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
{% endblock %} {% endblock %}

View file

@ -71,6 +71,7 @@ $table-border-color: #ccc;
align-items: center; align-items: center;
display: flex; display: flex;
flex: none; flex: none;
user-select: none;
width: 100%; width: 100%;
&--selected { &--selected {
@ -203,18 +204,51 @@ $table-border-color: #ccc;
} }
} }
.datum-editor { .table-viewer__datum-editor {
align-items: stretch;
border-top: globals.$default-border; border-top: globals.$default-border;
display: flex; display: flex;
grid-area: editor; grid-area: editor;
height: 12rem; height: 6rem;
}
&__input { .datum-editor {
@include globals.reset_input; &__container {
padding: 0.75rem 0.5rem; border-left: solid 4px transparent;
font-family: globals.$font-family-data; display: grid;
flex: 1; flex: 1;
grid-template: 'type-selector type-selector' max-content
'null-control value-control' max-content
'helpers helpers' auto / max-content auto;
&:has(:focus) {
border-left-color: #07f;
}
&--incomplete {
border-left-color: #f33;
}
}
&__type-selector {
grid-area: type-selector;
}
&__null-control {
@include globals.reset_button;
align-self: start;
grid-area: null-control;
padding: 0.75rem;
&--disabled {
opacity: 0.75;
}
}
&__text-input {
@include globals.reset_input;
grid-area: value-control;
font-family: globals.$font-family-data;
padding: 0.75rem 0.5rem;
} }
} }

View file

@ -1,23 +1,64 @@
<script lang="ts"> <script lang="ts">
import { type EditorState } from "./editor-state.svelte"; import icon_cube_transparent from "../assets/heroicons/20/solid/cube-transparent.svg?raw";
import icon_cube from "../assets/heroicons/20/solid/cube.svg?raw";
import type { Datum } from "./datum.svelte";
import {
datum_from_editor_state,
editor_state_from_datum,
type EditorState,
} from "./editor-state.svelte";
import { type FieldInfo } from "./field.svelte"; import { type FieldInfo } from "./field.svelte";
type Props = { type Props = {
/**
* For use cases in which the user may select between multiple datum types,
* such as when embedded in an expression editor, this array represents the
* permissible set of field parameters.
*/
assignable_fields?: ReadonlyArray<FieldInfo>; assignable_fields?: ReadonlyArray<FieldInfo>;
editor_state: EditorState;
field_info: FieldInfo; field_info: FieldInfo;
on_change?(value?: Datum): void;
value?: Datum;
}; };
let { let {
assignable_fields = [], assignable_fields = [],
editor_state = $bindable(),
field_info = $bindable(), field_info = $bindable(),
on_change,
value = $bindable(),
}: Props = $props(); }: Props = $props();
let editor_state = $state<EditorState | undefined>();
let type_selector_menu_button_element = $state< let type_selector_menu_button_element = $state<
HTMLButtonElement | undefined HTMLButtonElement | undefined
>(); >();
let type_selector_popover_element = $state<HTMLDivElement | undefined>(); let type_selector_popover_element = $state<HTMLDivElement | undefined>();
let text_input_element = $state<HTMLInputElement | undefined>();
$effect(() => {
if (value) {
editor_state = editor_state_from_datum(value);
}
});
export function focus() {
text_input_element?.focus();
}
function handle_input() {
if (!editor_state) {
console.warn("preconditions for handle_input() not met");
return;
}
value = datum_from_editor_state(
editor_state,
field_info.field.presentation,
);
on_change?.(value);
}
function handle_type_selector_menu_button_click() { function handle_type_selector_menu_button_click() {
type_selector_popover_element?.togglePopover(); type_selector_popover_element?.togglePopover();
@ -30,40 +71,76 @@
} }
</script> </script>
<div class="datum-editor__container"> <div
{#if assignable_fields.length > 0} class="datum-editor__container"
<div class="datum-editor__type-selector"> class:datum-editor__container--incomplete={!value}
<button >
bind:this={type_selector_menu_button_element} {#if editor_state}
class="datum-editor__type-selector-menu-button" {#if assignable_fields?.length > 0}
onclick={handle_type_selector_menu_button_click} <div class="datum-editor__type-selector">
type="button" <button
> bind:this={type_selector_menu_button_element}
{field_info.field.presentation.t} class="datum-editor__type-selector-menu-button"
</button> onclick={handle_type_selector_menu_button_click}
<div type="button"
bind:this={type_selector_popover_element} >
class="datum-editor__type-selector-popover" {field_info.field.presentation.t}
popover="auto" </button>
> <div
{#each assignable_fields as assignable_field_info} bind:this={type_selector_popover_element}
<button class="datum-editor__type-selector-popover"
onclick={() => popover="auto"
handle_type_selector_field_button_click(assignable_field_info)} >
type="button" {#each assignable_fields as assignable_field_info}
> <button
{assignable_field_info.field.presentation.t} onclick={() =>
</button> handle_type_selector_field_button_click(assignable_field_info)}
{/each} type="button"
>
{assignable_field_info.field.presentation.t}
</button>
{/each}
</div>
</div> </div>
</div>
{/if}
<div class="datum-editor__content">
{#if field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
<input bind:value={editor_state.text_value} type="text" />
{:else if field_info.field.presentation.t === "Timestamp"}
<input bind:value={editor_state.date_value} type="date" />
<input bind:value={editor_state.time_value} type="time" />
{/if} {/if}
</div> <button
type="button"
class="datum-editor__null-control"
class:datum-editor__null-control--disabled={editor_state.text_value !==
""}
disabled={editor_state.text_value !== ""}
onclick={() => {
if (!editor_state) {
console.warn("null control onclick() preconditions not met");
return;
}
editor_state.is_null = !editor_state.is_null;
handle_input();
}}
>
{#if editor_state.is_null}
{@html icon_cube_transparent}
{:else}
{@html icon_cube}
{/if}
</button>
{#if field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
<input
bind:this={text_input_element}
value={editor_state.text_value}
oninput={({ currentTarget: { value } }) => {
if (editor_state) {
editor_state.text_value = value;
handle_input();
}
}}
class="datum-editor__text-input"
type="text"
/>
{:else if field_info.field.presentation.t === "Timestamp"}
<input value={editor_state.date_value} type="date" />
<input value={editor_state.time_value} type="time" />
{/if}
<div class="datum-editor__helpers"></div>
{/if}
</div> </div>

View file

@ -57,8 +57,11 @@ export function datum_from_editor_state(
value: EditorState, value: EditorState,
presentation: Presentation, presentation: Presentation,
): Datum | undefined { ): Datum | undefined {
if (presentation.t === "Dropdown") {
return { t: "Text", c: value.is_null ? undefined : value.text_value };
}
if (presentation.t === "Text") { if (presentation.t === "Text") {
return { t: "Text", c: value.text_value }; return { t: "Text", c: value.is_null ? undefined : value.text_value };
} }
if (presentation.t === "Timestamp") { if (presentation.t === "Timestamp") {
// FIXME // FIXME
@ -66,7 +69,12 @@ export function datum_from_editor_state(
} }
if (presentation.t === "Uuid") { if (presentation.t === "Uuid") {
try { try {
return { t: "Uuid", c: uuid.stringify(uuid.parse(value.text_value)) }; return {
t: "Uuid",
c: value.is_null
? undefined
: uuid.stringify(uuid.parse(value.text_value)),
};
} catch { } catch {
// uuid.parse() throws a TypeError if unsuccessful. // uuid.parse() throws a TypeError if unsuccessful.
return undefined; return undefined;

View file

@ -14,14 +14,9 @@
import ExpressionSelector from "./expression-selector.svelte"; import ExpressionSelector from "./expression-selector.svelte";
import { type PgExpressionAny } from "./expression.svelte"; import { type PgExpressionAny } from "./expression.svelte";
import ExpressionEditor from "./expression-editor.webc.svelte"; import ExpressionEditor from "./expression-editor.webc.svelte";
import {
DEFAULT_EDITOR_STATE,
editor_state_from_datum,
type EditorState,
datum_from_editor_state,
} from "./editor-state.svelte";
import { type FieldInfo } from "./field.svelte"; import { type FieldInfo } from "./field.svelte";
import { type Presentation } from "./presentation.svelte"; import { type Presentation } from "./presentation.svelte";
import type { Datum } from "./datum.svelte";
const ASSIGNABLE_PRESENTATIONS: Presentation[] = [ const ASSIGNABLE_PRESENTATIONS: Presentation[] = [
{ t: "Text", c: { input_mode: { t: "MultiLine", c: {} } } }, { t: "Text", c: { input_mode: { t: "MultiLine", c: {} } } },
@ -49,23 +44,12 @@
let { identifier_hints = [], value = $bindable() }: Props = $props(); let { identifier_hints = [], value = $bindable() }: Props = $props();
let editor_state = $state<EditorState>( // Dynamic state to bind to datum editor.
value?.t === "Literal" let editor_value = $state<Datum | undefined>();
? editor_state_from_datum(value.c)
: DEFAULT_EDITOR_STATE,
);
let editor_field_info = $state<FieldInfo>(ASSIGNABLE_FIELDS[0]); let editor_field_info = $state<FieldInfo>(ASSIGNABLE_FIELDS[0]);
$effect(() => { $effect(() => {
if (value?.t === "Literal" && editor_field_info) { editor_value = value?.t === "Literal" ? value.c : undefined;
const datum_value = datum_from_editor_state(
editor_state,
editor_field_info.field.presentation,
);
if (datum_value) {
value.c = datum_value;
}
}
}); });
function handle_identifier_selector_change( function handle_identifier_selector_change(
@ -75,6 +59,12 @@
value.c.parts_raw = [ev.currentTarget.value]; value.c.parts_raw = [ev.currentTarget.value];
} }
} }
function handle_editor_change(datum_value: Datum) {
if (value?.t === "Literal") {
value.c = datum_value;
}
}
</script> </script>
<div class="expression-editor__container"> <div class="expression-editor__container">
@ -102,9 +92,10 @@
</select> </select>
{:else if value.t === "Literal"} {:else if value.t === "Literal"}
<DatumEditor <DatumEditor
bind:editor_state
bind:field_info={editor_field_info} bind:field_info={editor_field_info}
bind:value={editor_value}
assignable_fields={ASSIGNABLE_FIELDS} assignable_fields={ASSIGNABLE_FIELDS}
on_change={handle_editor_change}
/> />
{/if} {/if}
</div> </div>

View file

@ -17,11 +17,6 @@
import icon_sparkles from "../assets/heroicons/20/solid/sparkles.svg?raw"; import icon_sparkles from "../assets/heroicons/20/solid/sparkles.svg?raw";
import { type Datum, datum_schema } from "./datum.svelte"; import { type Datum, datum_schema } from "./datum.svelte";
import DatumEditor from "./datum-editor.svelte"; import DatumEditor from "./datum-editor.svelte";
import {
DEFAULT_EDITOR_STATE,
datum_from_editor_state,
type EditorState,
} from "./editor-state.svelte";
import { import {
type Coords, type Coords,
type Row, type Row,
@ -42,15 +37,18 @@
let { columns = [] }: Props = $props(); let { columns = [] }: Props = $props();
type CommittedChange = { type CellDelta = {
coords_initial: Coords; // Assumes that primary keys are immutable and that rows are only added or
// This will be identical to coords_initial, unless the change altered a // removed upon a refresh.
// primary key. coords: Coords;
coords_updated: Coords;
value_initial: Datum; value_initial: Datum;
value_updated: Datum; value_updated: Datum;
}; };
type Delta = {
cells: CellDelta[];
};
type LazyData = { type LazyData = {
rows: Row[]; rows: Row[];
fields: FieldInfo[]; fields: FieldInfo[];
@ -62,14 +60,27 @@
original_value: Datum; original_value: Datum;
}; };
type ParsedPkey = Record<string, Datum>;
let selections = $state<Selection[]>([]); let selections = $state<Selection[]>([]);
let editing = $state(false); // While the datum editor is focused and while updated values are being pushed
let editor_state = $state<EditorState>(DEFAULT_EDITOR_STATE); // to the server, other actions such as changing the set of selected cells are
let committed_changes = $state<CommittedChange[][]>([]); // restricted.
let reverted_changes = $state<CommittedChange[][]>([]); let editor_value = $state<Datum | undefined>(undefined);
let editor_input_element = $state<HTMLInputElement | undefined>(); let deltas = $state<{
commit_queued: Delta[];
commit_pending: Delta[];
committed: Delta[];
revert_queued: Delta[];
revert_pending: Delta[];
reverted: Delta[];
}>({
commit_queued: [],
commit_pending: [],
committed: [],
revert_queued: [],
revert_pending: [],
reverted: [],
});
let datum_editor = $state<DatumEditor | undefined>();
let table_element = $state<HTMLDivElement | undefined>(); let table_element = $state<HTMLDivElement | undefined>();
let inserter_rows = $state<Row[]>([]); let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>(); let lazy_data = $state<LazyData | undefined>();
@ -117,120 +128,103 @@
} else if (sel.region === "inserter") { } else if (sel.region === "inserter") {
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]]; cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
} }
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") { editor_value = cell_data;
editor_state.text_value = cell_data.c ?? "";
} else {
editor_state.text_value = "";
}
} else { } else {
editor_state.text_value = ""; editor_value = undefined;
} }
} }
function try_move_selection(direction: "Down" | "Left" | "Right" | "Up") { function move_selection(direction: "Down" | "Left" | "Right" | "Up") {
if (lazy_data && !editing && selections.length > 0) { if (!lazy_data || selections.length === 0) {
const last_selection = selections[selections.length - 1]; console.warn("move_selection() preconditions not met");
if ( return;
direction === "Right" && }
last_selection.coords[1] < lazy_data.fields.length - 1
) { const last_selection = selections[selections.length - 1];
set_selections([ if (
{ direction === "Right" &&
region: last_selection.region, last_selection.coords[1] < lazy_data.fields.length - 1
coords: [last_selection.coords[0], last_selection.coords[1] + 1], ) {
}, set_selections([
]); {
} else if (direction === "Left" && last_selection.coords[1] > 0) { region: last_selection.region,
set_selections([ coords: [last_selection.coords[0], last_selection.coords[1] + 1],
{ },
region: last_selection.region, ]);
coords: [last_selection.coords[0], last_selection.coords[1] - 1], } else if (direction === "Left" && last_selection.coords[1] > 0) {
}, set_selections([
]); {
} else if (direction === "Down") { region: last_selection.region,
if (last_selection.region === "main") { coords: [last_selection.coords[0], last_selection.coords[1] - 1],
if (last_selection.coords[0] < lazy_data.rows.length - 1) { },
set_selections([ ]);
{ } else if (direction === "Down") {
region: "main", if (last_selection.region === "main") {
coords: [ if (last_selection.coords[0] < lazy_data.rows.length - 1) {
last_selection.coords[0] + 1, set_selections([
last_selection.coords[1], {
], region: "main",
}, coords: [last_selection.coords[0] + 1, last_selection.coords[1]],
]); },
} else { ]);
// At bottom of main table. } else {
set_selections([ // At bottom of main table.
{ set_selections([
region: "inserter", {
coords: [0, last_selection.coords[1]], region: "inserter",
}, coords: [0, last_selection.coords[1]],
]); },
} ]);
} else if (last_selection.region === "inserter") {
if (last_selection.coords[0] < inserter_rows.length - 1) {
set_selections([
{
region: "inserter",
coords: [
last_selection.coords[0] + 1,
last_selection.coords[1],
],
},
]);
}
} }
} else if (direction === "Up") { } else if (last_selection.region === "inserter") {
if (last_selection.region === "main") { if (last_selection.coords[0] < inserter_rows.length - 1) {
if (last_selection.coords[0] > 0) { set_selections([
set_selections([ {
{ region: "inserter",
region: "main", coords: [last_selection.coords[0] + 1, last_selection.coords[1]],
coords: [ },
last_selection.coords[0] - 1, ]);
last_selection.coords[1], }
], }
}, } else if (direction === "Up") {
]); if (last_selection.region === "main") {
} if (last_selection.coords[0] > 0) {
} else if (last_selection.region === "inserter") { set_selections([
if (last_selection.coords[0] > 0) { {
set_selections([ region: "main",
{ coords: [last_selection.coords[0] - 1, last_selection.coords[1]],
region: "inserter", },
coords: [ ]);
last_selection.coords[0] - 1, }
last_selection.coords[1], } else if (last_selection.region === "inserter") {
], if (last_selection.coords[0] > 0) {
}, set_selections([
]); {
} else { region: "inserter",
// At top of inserter table. coords: [last_selection.coords[0] - 1, last_selection.coords[1]],
set_selections([ },
{ ]);
region: "main", } else {
coords: [lazy_data.rows.length - 1, last_selection.coords[1]], // At top of inserter table.
}, set_selections([
]); {
} region: "main",
coords: [lazy_data.rows.length - 1, last_selection.coords[1]],
},
]);
} }
} }
} }
} }
function try_sync_edit_to_cells() { function try_sync_edit_to_cells() {
if (lazy_data && editing && selections.length === 1) { if (lazy_data && selections.length === 1) {
const [sel] = selections; const [sel] = selections;
const parsed = datum_from_editor_state( if (editor_value !== undefined) {
editor_state,
lazy_data.fields[sel.coords[1]].field.presentation,
);
if (parsed !== undefined) {
if (sel.region === "main") { if (sel.region === "main") {
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = parsed; lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
} else if (sel.region === "inserter") { } else if (sel.region === "inserter") {
inserter_rows[sel.coords[0]].data[sel.coords[1]] = parsed; inserter_rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
} else { } else {
throw new Error("Unknown region"); throw new Error("Unknown region");
} }
@ -238,65 +232,70 @@
} }
} }
function try_start_edit() { function try_queue_delta() {
if (!editing) { // Copy `editor_value` so that it can be used intuitively within closures.
editing = true; const editor_value_scoped = editor_value;
editor_input_element?.focus(); if (editor_value_scoped === undefined) {
cancel_edit();
} else {
if (selections.length > 0) {
deltas.commit_queued = [
...deltas.commit_queued,
{
cells: selections
.filter(({ region }) => region === "main")
.map((sel) => ({
coords: sel.coords,
value_initial: sel.original_value,
value_updated: editor_value_scoped,
})),
},
];
selections = selections.map((sel) => ({
...sel,
original_value: editor_value_scoped,
}));
}
} }
} }
function try_commit_edit() { async function commit_delta(delta: Delta) {
(async function () { // Copy `lazy_data` so that it can be used intuitively within closures.
if (lazy_data && editing && editor_state && selections.length === 1) { const lazy_data_scoped = lazy_data;
const [sel] = selections; if (!lazy_data_scoped) {
const field = lazy_data.fields[sel.coords[1]]; console.warn("sync_delta() preconditions not met");
const parsed = datum_from_editor_state( return;
editor_state, }
field.field.presentation, deltas.commit_pending = [...deltas.commit_pending, delta];
); const resp = await fetch("update-values", {
if (parsed !== undefined) { method: "post",
if (sel.region === "main") { headers: { "Content-Type": "application/json" },
const pkey = JSON.parse( body: JSON.stringify({
lazy_data.rows[sel.coords[0]].key as string, cells: delta.cells.map((cell) => ({
) as ParsedPkey; pkey: JSON.parse(lazy_data_scoped.rows[cell.coords[0]].key as string),
const resp = await fetch("update-value", { column: lazy_data_scoped.fields[cell.coords[1]].field.name,
method: "post", value: cell.value_updated,
headers: { "Content-Type": "application/json" }, })),
body: JSON.stringify({ }),
column: field.field.name, });
pkeys: pkey, if (resp.status >= 200 && resp.status < 300) {
value: parsed, deltas.commit_pending = deltas.commit_pending.filter((x) => x !== delta);
}), deltas.committed = [...deltas.committed, delta];
}); } else {
if (resp.status >= 200 && resp.status < 300) { // TODO display feedback to user
committed_changes.push([ console.error(resp);
{ console.error(await resp.text());
coords_initial: sel.coords, }
coords_updated: sel.coords, // TODO: this assumes no inserted/deleted rows }
value_initial: sel.original_value,
value_updated: parsed, function tick_delta_queue() {
}, const front_of_queue: Delta | undefined = deltas.commit_queued[0];
]); if (front_of_queue) {
editing = false; deltas.commit_queued = deltas.commit_queued.filter(
selections = [{ ...sel, original_value: parsed }]; (x) => x !== front_of_queue,
table_element?.focus(); );
} else { commit_delta(front_of_queue).catch(console.error);
// TODO display feedback to user }
console.error(resp);
console.error(await resp.text());
}
} else if (sel.region === "inserter") {
table_element?.focus();
editing = false;
selections = [{ ...sel, original_value: parsed }];
} else {
throw new Error("Unknown region");
}
} else {
// TODO
}
}
})().catch(console.error);
} }
function cancel_edit() { function cancel_edit() {
@ -313,7 +312,6 @@
}); });
// Reset editor input value // Reset editor input value
set_selections(selections); set_selections(selections);
editing = false;
table_element?.focus(); table_element?.focus();
} }
@ -323,7 +321,7 @@
if (lazy_data) { if (lazy_data) {
const arrow_direction = arrow_key_direction(ev.key); const arrow_direction = arrow_key_direction(ev.key);
if (arrow_direction) { if (arrow_direction) {
try_move_selection(arrow_direction); move_selection(arrow_direction);
ev.preventDefault(); ev.preventDefault();
} }
if (ev.key === "Enter") { if (ev.key === "Enter") {
@ -347,69 +345,33 @@
]; ];
} }
} else { } else {
try_start_edit(); datum_editor?.focus();
} }
} }
} }
} }
function handle_table_cell_dblclick(_: Coords) {
try_start_edit();
}
function handle_table_focus() {
if (selections.length === 0 && (lazy_data?.rows[0]?.data.length ?? 0) > 0) {
set_selections([{ region: "main", coords: [0, 0] }]);
}
}
// -------- Event Handlers: Main Table -------- // // -------- Event Handlers: Main Table -------- //
function handle_main_cell_click(ev: MouseEvent, coords: Coords) { function handle_main_cell_click(ev: MouseEvent, coords: Coords) {
if (!editing) { if (ev.metaKey || ev.ctrlKey) {
if (ev.metaKey || ev.ctrlKey) { // TODO
// TODO // selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords]; // editor_input_value = "";
// editor_input_value = ""; } else {
} else { set_selections([{ region: "main", coords }]);
set_selections([{ region: "main", coords }]);
}
} }
} }
// -------- Event Handlers: Inserter Table -------- // // -------- Event Handlers: Inserter Table -------- //
function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) { function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) {
if (!editing) { if (ev.metaKey || ev.ctrlKey) {
if (ev.metaKey || ev.ctrlKey) { // TODO
// TODO // selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords]; // editor_input_value = "";
// editor_input_value = ""; } else {
} else { set_selections([{ region: "inserter", coords }]);
set_selections([{ region: "inserter", coords }]);
}
}
}
// -------- Event Handlers: Editor -------- //
function handle_editor_blur() {
try_commit_edit();
}
function handle_editor_focus() {
try_start_edit();
}
function handle_editor_input() {
try_sync_edit_to_cells();
}
function handle_editor_keydown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
try_commit_edit();
} else if (ev.key === "Escape") {
cancel_edit();
} }
} }
@ -440,6 +402,8 @@
}, },
]; ];
})().catch(console.error); })().catch(console.error);
setInterval(tick_delta_queue, 500);
</script> </script>
{#snippet table_region({ {#snippet table_region({
@ -474,24 +438,21 @@
aria-selected={cell_selected} aria-selected={cell_selected}
class="lens-table__cell" class="lens-table__cell"
onmousedown={(ev) => on_cell_click(ev, cell_coords)} onmousedown={(ev) => on_cell_click(ev, cell_coords)}
ondblclick={() => handle_table_cell_dblclick(cell_coords)} ondblclick={() => {
datum_editor?.focus();
}}
role="gridcell" role="gridcell"
style:width={`${field.field.table_width_px}px`} style:width={`${field.field.table_width_px}px`}
tabindex="-1" tabindex="-1"
> >
<div <div
class={[ class="lens-cell__container"
"lens-cell__container", class:lens-cell__container--selected={cell_selected}
cell_selected && "lens-cell__container--selected",
]}
> >
{#if cell_data.t === "Text"} {#if cell_data.t === "Text"}
<div <div
class={[ class="lens-cell__content lens-cell__content--text"
"lens-cell__content", class:lens-cell__content--null={cell_data.c === undefined}
"lens-cell__content--text",
cell_data.c === undefined && "lens-cell__content--null",
]}
> >
{#if cell_data.c === undefined} {#if cell_data.c === undefined}
{@html null_value_html} {@html null_value_html}
@ -501,11 +462,8 @@
</div> </div>
{:else if cell_data.t === "Uuid"} {:else if cell_data.t === "Uuid"}
<div <div
class={[ class="lens-cell__content lens-cell__content--uuid"
"lens-cell__content", class:lens-cell__content--null={cell_data.c === undefined}
"lens-cell__content--uuid",
cell_data.c === undefined && "lens-cell__content--null",
]}
> >
{#if cell_data.c === undefined} {#if cell_data.c === undefined}
{@html null_value_html} {@html null_value_html}
@ -514,9 +472,7 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<div <div class="lens-cell__content lens-cell__content--unknown">
class={["lens-cell__content", "lens-cell__content--unknown"]}
>
<div>UNKNOWN</div> <div>UNKNOWN</div>
</div> </div>
{/if} {/if}
@ -538,7 +494,9 @@
<div <div
bind:this={table_element} bind:this={table_element}
class="lens-table" class="lens-table"
onfocus={handle_table_focus} onfocus={() => {
try_queue_delta();
}}
onkeydown={handle_table_keydown} onkeydown={handle_table_keydown}
role="grid" role="grid"
tabindex="0" tabindex="0"
@ -593,28 +551,17 @@
{/each} {/each}
</form> </form>
</div> </div>
<div class="datum-editor"> <div class="table-viewer__datum-editor">
{#if selections.length === 1 && editor_state} {#if selections.length === 1}
<DatumEditor <DatumEditor
bind:editor_state bind:this={datum_editor}
bind:value={editor_value}
field_info={lazy_data.fields[selections[0].coords[1]]} field_info={lazy_data.fields[selections[0].coords[1]]}
on_change={() => {
try_sync_edit_to_cells();
}}
/> />
{/if} {/if}
<!--
<input
bind:this={editor_input_element}
bind:value={editor_input_value}
class={[
"lens-editor__input",
selections.length !== 1 && "lens-editor__input--hidden",
]}
onblur={handle_editor_blur}
onfocus={handle_editor_focus}
oninput={handle_editor_input}
onkeydown={handle_editor_keydown}
tabindex="-1"
/>
-->
</div> </div>
{/if} {/if}
</div> </div>