repair datum editor
This commit is contained in:
parent
395a547b94
commit
5a24454787
9 changed files with 417 additions and 354 deletions
|
|
@ -72,7 +72,7 @@ pub(super) async fn get(
|
|||
};
|
||||
|
||||
let mut sql_raw = format!(
|
||||
"select {0} from {1}.{2}",
|
||||
"select {0} from {1}.{2} order by _id",
|
||||
pkey_attrs
|
||||
.iter()
|
||||
.chain(attrs.iter())
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ mod update_form_transitions_handler;
|
|||
mod update_portal_name_handler;
|
||||
mod update_prompts_handler;
|
||||
mod update_rel_name_handler;
|
||||
mod update_value_handler;
|
||||
mod update_values_handler;
|
||||
|
||||
pub(super) fn new_router() -> Router<App> {
|
||||
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}/update-value",
|
||||
post(update_value_handler::post),
|
||||
"/p/{portal_id}/update-values",
|
||||
post(update_values_handler::post),
|
||||
)
|
||||
.route("/p/{portal_id}/set-filter", post(set_filter_handler::post))
|
||||
.route_with_tsr("/p/{portal_id}/form/", get(form_handler::get))
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
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::{
|
||||
datum::Datum,
|
||||
portal::Portal,
|
||||
|
|
@ -17,7 +14,7 @@ use interim_models::{
|
|||
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::{postgres::types::Oid, query};
|
||||
use sqlx::{Acquire as _, postgres::types::Oid, query};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
|
|
@ -36,12 +33,17 @@ pub(super) struct PathParams {
|
|||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FormBody {
|
||||
cells: Vec<CellInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct CellInfo {
|
||||
column: String,
|
||||
pkeys: HashMap<String, Datum>,
|
||||
pkey: HashMap<String, 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
|
||||
/// [`PathParams`].
|
||||
|
|
@ -55,7 +57,7 @@ pub(super) async fn post(
|
|||
rel_oid,
|
||||
workspace_id,
|
||||
}): Path<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
Json(form): Json<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// Check workspace authorization.
|
||||
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.
|
||||
|
||||
// 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"));
|
||||
}
|
||||
|
||||
|
|
@ -91,19 +93,24 @@ pub(super) async fn post(
|
|||
.fetch_all(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
// TODO: simplify pkey management
|
||||
form.pkeys
|
||||
.get(&pkey_attrs.first().unwrap().attname)
|
||||
.unwrap()
|
||||
.clone()
|
||||
.bind_onto(form.value.bind_onto(query(&format!(
|
||||
"update {ident} set {value_col} = $1 where {pkey_col} = $2",
|
||||
ident = rel.get_identifier(),
|
||||
value_col = escape_identifier(&form.column),
|
||||
pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname),
|
||||
))))
|
||||
.execute(workspace_client.get_conn())
|
||||
.await?;
|
||||
let conn = workspace_client.get_conn();
|
||||
let mut txn = conn.begin().await?;
|
||||
for cell in form.cells {
|
||||
// TODO: simplify pkey management
|
||||
cell.pkey
|
||||
.get(&pkey_attrs.first().unwrap().attname)
|
||||
.unwrap()
|
||||
.clone()
|
||||
.bind_onto(cell.value.bind_onto(query(&format!(
|
||||
"update {ident} set {value_col} = $1 where {pkey_col} = $2",
|
||||
ident = rel.get_identifier(),
|
||||
value_col = escape_identifier(&cell.column),
|
||||
pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname),
|
||||
))))
|
||||
.execute(&mut *txn)
|
||||
.await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
|
||||
Ok(Json(json!({ "ok": true })).into_response())
|
||||
}
|
||||
|
|
@ -26,6 +26,5 @@
|
|||
</main>
|
||||
</div>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ $table-border-color: #ccc;
|
|||
align-items: center;
|
||||
display: flex;
|
||||
flex: none;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
|
||||
&--selected {
|
||||
|
|
@ -203,18 +204,51 @@ $table-border-color: #ccc;
|
|||
}
|
||||
}
|
||||
|
||||
.datum-editor {
|
||||
align-items: stretch;
|
||||
.table-viewer__datum-editor {
|
||||
border-top: globals.$default-border;
|
||||
display: flex;
|
||||
grid-area: editor;
|
||||
height: 12rem;
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
&__input {
|
||||
@include globals.reset_input;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-family: globals.$font-family-data;
|
||||
.datum-editor {
|
||||
&__container {
|
||||
border-left: solid 4px transparent;
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,64 @@
|
|||
<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";
|
||||
|
||||
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>;
|
||||
editor_state: EditorState;
|
||||
|
||||
field_info: FieldInfo;
|
||||
|
||||
on_change?(value?: Datum): void;
|
||||
|
||||
value?: Datum;
|
||||
};
|
||||
|
||||
let {
|
||||
assignable_fields = [],
|
||||
editor_state = $bindable(),
|
||||
field_info = $bindable(),
|
||||
on_change,
|
||||
value = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let editor_state = $state<EditorState | undefined>();
|
||||
let type_selector_menu_button_element = $state<
|
||||
HTMLButtonElement | 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() {
|
||||
type_selector_popover_element?.togglePopover();
|
||||
|
|
@ -30,40 +71,76 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<div class="datum-editor__container">
|
||||
{#if assignable_fields.length > 0}
|
||||
<div class="datum-editor__type-selector">
|
||||
<button
|
||||
bind:this={type_selector_menu_button_element}
|
||||
class="datum-editor__type-selector-menu-button"
|
||||
onclick={handle_type_selector_menu_button_click}
|
||||
type="button"
|
||||
>
|
||||
{field_info.field.presentation.t}
|
||||
</button>
|
||||
<div
|
||||
bind:this={type_selector_popover_element}
|
||||
class="datum-editor__type-selector-popover"
|
||||
popover="auto"
|
||||
>
|
||||
{#each assignable_fields as assignable_field_info}
|
||||
<button
|
||||
onclick={() =>
|
||||
handle_type_selector_field_button_click(assignable_field_info)}
|
||||
type="button"
|
||||
>
|
||||
{assignable_field_info.field.presentation.t}
|
||||
</button>
|
||||
{/each}
|
||||
<div
|
||||
class="datum-editor__container"
|
||||
class:datum-editor__container--incomplete={!value}
|
||||
>
|
||||
{#if editor_state}
|
||||
{#if assignable_fields?.length > 0}
|
||||
<div class="datum-editor__type-selector">
|
||||
<button
|
||||
bind:this={type_selector_menu_button_element}
|
||||
class="datum-editor__type-selector-menu-button"
|
||||
onclick={handle_type_selector_menu_button_click}
|
||||
type="button"
|
||||
>
|
||||
{field_info.field.presentation.t}
|
||||
</button>
|
||||
<div
|
||||
bind:this={type_selector_popover_element}
|
||||
class="datum-editor__type-selector-popover"
|
||||
popover="auto"
|
||||
>
|
||||
{#each assignable_fields as assignable_field_info}
|
||||
<button
|
||||
onclick={() =>
|
||||
handle_type_selector_field_button_click(assignable_field_info)}
|
||||
type="button"
|
||||
>
|
||||
{assignable_field_info.field.presentation.t}
|
||||
</button>
|
||||
{/each}
|
||||
</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}
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -57,8 +57,11 @@ export function datum_from_editor_state(
|
|||
value: EditorState,
|
||||
presentation: Presentation,
|
||||
): Datum | undefined {
|
||||
if (presentation.t === "Dropdown") {
|
||||
return { t: "Text", c: value.is_null ? undefined : value.text_value };
|
||||
}
|
||||
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") {
|
||||
// FIXME
|
||||
|
|
@ -66,7 +69,12 @@ export function datum_from_editor_state(
|
|||
}
|
||||
if (presentation.t === "Uuid") {
|
||||
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 {
|
||||
// uuid.parse() throws a TypeError if unsuccessful.
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -14,14 +14,9 @@
|
|||
import ExpressionSelector from "./expression-selector.svelte";
|
||||
import { type PgExpressionAny } from "./expression.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 Presentation } from "./presentation.svelte";
|
||||
import type { Datum } from "./datum.svelte";
|
||||
|
||||
const ASSIGNABLE_PRESENTATIONS: Presentation[] = [
|
||||
{ t: "Text", c: { input_mode: { t: "MultiLine", c: {} } } },
|
||||
|
|
@ -49,23 +44,12 @@
|
|||
|
||||
let { identifier_hints = [], value = $bindable() }: Props = $props();
|
||||
|
||||
let editor_state = $state<EditorState>(
|
||||
value?.t === "Literal"
|
||||
? editor_state_from_datum(value.c)
|
||||
: DEFAULT_EDITOR_STATE,
|
||||
);
|
||||
// Dynamic state to bind to datum editor.
|
||||
let editor_value = $state<Datum | undefined>();
|
||||
let editor_field_info = $state<FieldInfo>(ASSIGNABLE_FIELDS[0]);
|
||||
|
||||
$effect(() => {
|
||||
if (value?.t === "Literal" && editor_field_info) {
|
||||
const datum_value = datum_from_editor_state(
|
||||
editor_state,
|
||||
editor_field_info.field.presentation,
|
||||
);
|
||||
if (datum_value) {
|
||||
value.c = datum_value;
|
||||
}
|
||||
}
|
||||
editor_value = value?.t === "Literal" ? value.c : undefined;
|
||||
});
|
||||
|
||||
function handle_identifier_selector_change(
|
||||
|
|
@ -75,6 +59,12 @@
|
|||
value.c.parts_raw = [ev.currentTarget.value];
|
||||
}
|
||||
}
|
||||
|
||||
function handle_editor_change(datum_value: Datum) {
|
||||
if (value?.t === "Literal") {
|
||||
value.c = datum_value;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="expression-editor__container">
|
||||
|
|
@ -102,9 +92,10 @@
|
|||
</select>
|
||||
{:else if value.t === "Literal"}
|
||||
<DatumEditor
|
||||
bind:editor_state
|
||||
bind:field_info={editor_field_info}
|
||||
bind:value={editor_value}
|
||||
assignable_fields={ASSIGNABLE_FIELDS}
|
||||
on_change={handle_editor_change}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,11 +17,6 @@
|
|||
import icon_sparkles from "../assets/heroicons/20/solid/sparkles.svg?raw";
|
||||
import { type Datum, datum_schema } from "./datum.svelte";
|
||||
import DatumEditor from "./datum-editor.svelte";
|
||||
import {
|
||||
DEFAULT_EDITOR_STATE,
|
||||
datum_from_editor_state,
|
||||
type EditorState,
|
||||
} from "./editor-state.svelte";
|
||||
import {
|
||||
type Coords,
|
||||
type Row,
|
||||
|
|
@ -42,15 +37,18 @@
|
|||
|
||||
let { columns = [] }: Props = $props();
|
||||
|
||||
type CommittedChange = {
|
||||
coords_initial: Coords;
|
||||
// This will be identical to coords_initial, unless the change altered a
|
||||
// primary key.
|
||||
coords_updated: Coords;
|
||||
type CellDelta = {
|
||||
// Assumes that primary keys are immutable and that rows are only added or
|
||||
// removed upon a refresh.
|
||||
coords: Coords;
|
||||
value_initial: Datum;
|
||||
value_updated: Datum;
|
||||
};
|
||||
|
||||
type Delta = {
|
||||
cells: CellDelta[];
|
||||
};
|
||||
|
||||
type LazyData = {
|
||||
rows: Row[];
|
||||
fields: FieldInfo[];
|
||||
|
|
@ -62,14 +60,27 @@
|
|||
original_value: Datum;
|
||||
};
|
||||
|
||||
type ParsedPkey = Record<string, Datum>;
|
||||
|
||||
let selections = $state<Selection[]>([]);
|
||||
let editing = $state(false);
|
||||
let editor_state = $state<EditorState>(DEFAULT_EDITOR_STATE);
|
||||
let committed_changes = $state<CommittedChange[][]>([]);
|
||||
let reverted_changes = $state<CommittedChange[][]>([]);
|
||||
let editor_input_element = $state<HTMLInputElement | undefined>();
|
||||
// While the datum editor is focused and while updated values are being pushed
|
||||
// to the server, other actions such as changing the set of selected cells are
|
||||
// restricted.
|
||||
let editor_value = $state<Datum | undefined>(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 inserter_rows = $state<Row[]>([]);
|
||||
let lazy_data = $state<LazyData | undefined>();
|
||||
|
|
@ -117,120 +128,103 @@
|
|||
} else if (sel.region === "inserter") {
|
||||
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
|
||||
}
|
||||
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") {
|
||||
editor_state.text_value = cell_data.c ?? "";
|
||||
} else {
|
||||
editor_state.text_value = "";
|
||||
}
|
||||
editor_value = cell_data;
|
||||
} else {
|
||||
editor_state.text_value = "";
|
||||
editor_value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function try_move_selection(direction: "Down" | "Left" | "Right" | "Up") {
|
||||
if (lazy_data && !editing && selections.length > 0) {
|
||||
const last_selection = selections[selections.length - 1];
|
||||
if (
|
||||
direction === "Right" &&
|
||||
last_selection.coords[1] < lazy_data.fields.length - 1
|
||||
) {
|
||||
set_selections([
|
||||
{
|
||||
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([
|
||||
{
|
||||
region: last_selection.region,
|
||||
coords: [last_selection.coords[0], last_selection.coords[1] - 1],
|
||||
},
|
||||
]);
|
||||
} else if (direction === "Down") {
|
||||
if (last_selection.region === "main") {
|
||||
if (last_selection.coords[0] < lazy_data.rows.length - 1) {
|
||||
set_selections([
|
||||
{
|
||||
region: "main",
|
||||
coords: [
|
||||
last_selection.coords[0] + 1,
|
||||
last_selection.coords[1],
|
||||
],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// At bottom of main table.
|
||||
set_selections([
|
||||
{
|
||||
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],
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
function move_selection(direction: "Down" | "Left" | "Right" | "Up") {
|
||||
if (!lazy_data || selections.length === 0) {
|
||||
console.warn("move_selection() preconditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
const last_selection = selections[selections.length - 1];
|
||||
if (
|
||||
direction === "Right" &&
|
||||
last_selection.coords[1] < lazy_data.fields.length - 1
|
||||
) {
|
||||
set_selections([
|
||||
{
|
||||
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([
|
||||
{
|
||||
region: last_selection.region,
|
||||
coords: [last_selection.coords[0], last_selection.coords[1] - 1],
|
||||
},
|
||||
]);
|
||||
} else if (direction === "Down") {
|
||||
if (last_selection.region === "main") {
|
||||
if (last_selection.coords[0] < lazy_data.rows.length - 1) {
|
||||
set_selections([
|
||||
{
|
||||
region: "main",
|
||||
coords: [last_selection.coords[0] + 1, last_selection.coords[1]],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// At bottom of main table.
|
||||
set_selections([
|
||||
{
|
||||
region: "inserter",
|
||||
coords: [0, last_selection.coords[1]],
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (direction === "Up") {
|
||||
if (last_selection.region === "main") {
|
||||
if (last_selection.coords[0] > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: "main",
|
||||
coords: [
|
||||
last_selection.coords[0] - 1,
|
||||
last_selection.coords[1],
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (last_selection.region === "inserter") {
|
||||
if (last_selection.coords[0] > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: "inserter",
|
||||
coords: [
|
||||
last_selection.coords[0] - 1,
|
||||
last_selection.coords[1],
|
||||
],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// At top of inserter table.
|
||||
set_selections([
|
||||
{
|
||||
region: "main",
|
||||
coords: [lazy_data.rows.length - 1, 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") {
|
||||
if (last_selection.region === "main") {
|
||||
if (last_selection.coords[0] > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: "main",
|
||||
coords: [last_selection.coords[0] - 1, last_selection.coords[1]],
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else if (last_selection.region === "inserter") {
|
||||
if (last_selection.coords[0] > 0) {
|
||||
set_selections([
|
||||
{
|
||||
region: "inserter",
|
||||
coords: [last_selection.coords[0] - 1, last_selection.coords[1]],
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
// 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() {
|
||||
if (lazy_data && editing && selections.length === 1) {
|
||||
if (lazy_data && selections.length === 1) {
|
||||
const [sel] = selections;
|
||||
const parsed = datum_from_editor_state(
|
||||
editor_state,
|
||||
lazy_data.fields[sel.coords[1]].field.presentation,
|
||||
);
|
||||
if (parsed !== undefined) {
|
||||
if (editor_value !== undefined) {
|
||||
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") {
|
||||
inserter_rows[sel.coords[0]].data[sel.coords[1]] = parsed;
|
||||
inserter_rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
||||
} else {
|
||||
throw new Error("Unknown region");
|
||||
}
|
||||
|
|
@ -238,65 +232,70 @@
|
|||
}
|
||||
}
|
||||
|
||||
function try_start_edit() {
|
||||
if (!editing) {
|
||||
editing = true;
|
||||
editor_input_element?.focus();
|
||||
function try_queue_delta() {
|
||||
// Copy `editor_value` so that it can be used intuitively within closures.
|
||||
const editor_value_scoped = editor_value;
|
||||
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 () {
|
||||
if (lazy_data && editing && editor_state && selections.length === 1) {
|
||||
const [sel] = selections;
|
||||
const field = lazy_data.fields[sel.coords[1]];
|
||||
const parsed = datum_from_editor_state(
|
||||
editor_state,
|
||||
field.field.presentation,
|
||||
);
|
||||
if (parsed !== undefined) {
|
||||
if (sel.region === "main") {
|
||||
const pkey = JSON.parse(
|
||||
lazy_data.rows[sel.coords[0]].key as string,
|
||||
) as ParsedPkey;
|
||||
const resp = await fetch("update-value", {
|
||||
method: "post",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
column: field.field.name,
|
||||
pkeys: pkey,
|
||||
value: parsed,
|
||||
}),
|
||||
});
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
committed_changes.push([
|
||||
{
|
||||
coords_initial: sel.coords,
|
||||
coords_updated: sel.coords, // TODO: this assumes no inserted/deleted rows
|
||||
value_initial: sel.original_value,
|
||||
value_updated: parsed,
|
||||
},
|
||||
]);
|
||||
editing = false;
|
||||
selections = [{ ...sel, original_value: parsed }];
|
||||
table_element?.focus();
|
||||
} else {
|
||||
// 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);
|
||||
async function commit_delta(delta: Delta) {
|
||||
// Copy `lazy_data` so that it can be used intuitively within closures.
|
||||
const lazy_data_scoped = lazy_data;
|
||||
if (!lazy_data_scoped) {
|
||||
console.warn("sync_delta() preconditions not met");
|
||||
return;
|
||||
}
|
||||
deltas.commit_pending = [...deltas.commit_pending, delta];
|
||||
const resp = await fetch("update-values", {
|
||||
method: "post",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
cells: delta.cells.map((cell) => ({
|
||||
pkey: JSON.parse(lazy_data_scoped.rows[cell.coords[0]].key as string),
|
||||
column: lazy_data_scoped.fields[cell.coords[1]].field.name,
|
||||
value: cell.value_updated,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (resp.status >= 200 && resp.status < 300) {
|
||||
deltas.commit_pending = deltas.commit_pending.filter((x) => x !== delta);
|
||||
deltas.committed = [...deltas.committed, delta];
|
||||
} else {
|
||||
// TODO display feedback to user
|
||||
console.error(resp);
|
||||
console.error(await resp.text());
|
||||
}
|
||||
}
|
||||
|
||||
function tick_delta_queue() {
|
||||
const front_of_queue: Delta | undefined = deltas.commit_queued[0];
|
||||
if (front_of_queue) {
|
||||
deltas.commit_queued = deltas.commit_queued.filter(
|
||||
(x) => x !== front_of_queue,
|
||||
);
|
||||
commit_delta(front_of_queue).catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel_edit() {
|
||||
|
|
@ -313,7 +312,6 @@
|
|||
});
|
||||
// Reset editor input value
|
||||
set_selections(selections);
|
||||
editing = false;
|
||||
table_element?.focus();
|
||||
}
|
||||
|
||||
|
|
@ -323,7 +321,7 @@
|
|||
if (lazy_data) {
|
||||
const arrow_direction = arrow_key_direction(ev.key);
|
||||
if (arrow_direction) {
|
||||
try_move_selection(arrow_direction);
|
||||
move_selection(arrow_direction);
|
||||
ev.preventDefault();
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
|
|
@ -347,69 +345,33 @@
|
|||
];
|
||||
}
|
||||
} 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 -------- //
|
||||
|
||||
function handle_main_cell_click(ev: MouseEvent, coords: Coords) {
|
||||
if (!editing) {
|
||||
if (ev.metaKey || ev.ctrlKey) {
|
||||
// TODO
|
||||
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||
// editor_input_value = "";
|
||||
} else {
|
||||
set_selections([{ region: "main", coords }]);
|
||||
}
|
||||
if (ev.metaKey || ev.ctrlKey) {
|
||||
// TODO
|
||||
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||
// editor_input_value = "";
|
||||
} else {
|
||||
set_selections([{ region: "main", coords }]);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- Event Handlers: Inserter Table -------- //
|
||||
|
||||
function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) {
|
||||
if (!editing) {
|
||||
if (ev.metaKey || ev.ctrlKey) {
|
||||
// TODO
|
||||
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||
// editor_input_value = "";
|
||||
} else {
|
||||
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();
|
||||
if (ev.metaKey || ev.ctrlKey) {
|
||||
// TODO
|
||||
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||
// editor_input_value = "";
|
||||
} else {
|
||||
set_selections([{ region: "inserter", coords }]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -440,6 +402,8 @@
|
|||
},
|
||||
];
|
||||
})().catch(console.error);
|
||||
|
||||
setInterval(tick_delta_queue, 500);
|
||||
</script>
|
||||
|
||||
{#snippet table_region({
|
||||
|
|
@ -474,24 +438,21 @@
|
|||
aria-selected={cell_selected}
|
||||
class="lens-table__cell"
|
||||
onmousedown={(ev) => on_cell_click(ev, cell_coords)}
|
||||
ondblclick={() => handle_table_cell_dblclick(cell_coords)}
|
||||
ondblclick={() => {
|
||||
datum_editor?.focus();
|
||||
}}
|
||||
role="gridcell"
|
||||
style:width={`${field.field.table_width_px}px`}
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class={[
|
||||
"lens-cell__container",
|
||||
cell_selected && "lens-cell__container--selected",
|
||||
]}
|
||||
class="lens-cell__container"
|
||||
class:lens-cell__container--selected={cell_selected}
|
||||
>
|
||||
{#if cell_data.t === "Text"}
|
||||
<div
|
||||
class={[
|
||||
"lens-cell__content",
|
||||
"lens-cell__content--text",
|
||||
cell_data.c === undefined && "lens-cell__content--null",
|
||||
]}
|
||||
class="lens-cell__content lens-cell__content--text"
|
||||
class:lens-cell__content--null={cell_data.c === undefined}
|
||||
>
|
||||
{#if cell_data.c === undefined}
|
||||
{@html null_value_html}
|
||||
|
|
@ -501,11 +462,8 @@
|
|||
</div>
|
||||
{:else if cell_data.t === "Uuid"}
|
||||
<div
|
||||
class={[
|
||||
"lens-cell__content",
|
||||
"lens-cell__content--uuid",
|
||||
cell_data.c === undefined && "lens-cell__content--null",
|
||||
]}
|
||||
class="lens-cell__content lens-cell__content--uuid"
|
||||
class:lens-cell__content--null={cell_data.c === undefined}
|
||||
>
|
||||
{#if cell_data.c === undefined}
|
||||
{@html null_value_html}
|
||||
|
|
@ -514,9 +472,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class={["lens-cell__content", "lens-cell__content--unknown"]}
|
||||
>
|
||||
<div class="lens-cell__content lens-cell__content--unknown">
|
||||
<div>UNKNOWN</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -538,7 +494,9 @@
|
|||
<div
|
||||
bind:this={table_element}
|
||||
class="lens-table"
|
||||
onfocus={handle_table_focus}
|
||||
onfocus={() => {
|
||||
try_queue_delta();
|
||||
}}
|
||||
onkeydown={handle_table_keydown}
|
||||
role="grid"
|
||||
tabindex="0"
|
||||
|
|
@ -593,28 +551,17 @@
|
|||
{/each}
|
||||
</form>
|
||||
</div>
|
||||
<div class="datum-editor">
|
||||
{#if selections.length === 1 && editor_state}
|
||||
<div class="table-viewer__datum-editor">
|
||||
{#if selections.length === 1}
|
||||
<DatumEditor
|
||||
bind:editor_state
|
||||
bind:this={datum_editor}
|
||||
bind:value={editor_value}
|
||||
field_info={lazy_data.fields[selections[0].coords[1]]}
|
||||
on_change={() => {
|
||||
try_sync_edit_to_cells();
|
||||
}}
|
||||
/>
|
||||
{/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>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue