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!(
|
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())
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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?;
|
||||||
|
|
||||||
|
let conn = workspace_client.get_conn();
|
||||||
|
let mut txn = conn.begin().await?;
|
||||||
|
for cell in form.cells {
|
||||||
// TODO: simplify pkey management
|
// TODO: simplify pkey management
|
||||||
form.pkeys
|
cell.pkey
|
||||||
.get(&pkey_attrs.first().unwrap().attname)
|
.get(&pkey_attrs.first().unwrap().attname)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.clone()
|
.clone()
|
||||||
.bind_onto(form.value.bind_onto(query(&format!(
|
.bind_onto(cell.value.bind_onto(query(&format!(
|
||||||
"update {ident} set {value_col} = $1 where {pkey_col} = $2",
|
"update {ident} set {value_col} = $1 where {pkey_col} = $2",
|
||||||
ident = rel.get_identifier(),
|
ident = rel.get_identifier(),
|
||||||
value_col = escape_identifier(&form.column),
|
value_col = escape_identifier(&cell.column),
|
||||||
pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname),
|
pkey_col = escape_identifier(&pkey_attrs.first().unwrap().attname),
|
||||||
))))
|
))))
|
||||||
.execute(workspace_client.get_conn())
|
.execute(&mut *txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
Ok(Json(json!({ "ok": true })).into_response())
|
Ok(Json(json!({ "ok": true })).into_response())
|
||||||
}
|
}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,8 +71,12 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="datum-editor__container">
|
<div
|
||||||
{#if assignable_fields.length > 0}
|
class="datum-editor__container"
|
||||||
|
class:datum-editor__container--incomplete={!value}
|
||||||
|
>
|
||||||
|
{#if editor_state}
|
||||||
|
{#if assignable_fields?.length > 0}
|
||||||
<div class="datum-editor__type-selector">
|
<div class="datum-editor__type-selector">
|
||||||
<button
|
<button
|
||||||
bind:this={type_selector_menu_button_element}
|
bind:this={type_selector_menu_button_element}
|
||||||
|
|
@ -58,12 +103,44 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="datum-editor__content">
|
<button
|
||||||
{#if field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
|
type="button"
|
||||||
<input bind:value={editor_state.text_value} type="text" />
|
class="datum-editor__null-control"
|
||||||
{:else if field_info.field.presentation.t === "Timestamp"}
|
class:datum-editor__null-control--disabled={editor_state.text_value !==
|
||||||
<input bind:value={editor_state.date_value} type="date" />
|
""}
|
||||||
<input bind:value={editor_state.time_value} type="time" />
|
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}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,18 +128,18 @@
|
||||||
} 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 {
|
} else {
|
||||||
editor_state.text_value = "";
|
editor_value = undefined;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
editor_state.text_value = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
console.warn("move_selection() preconditions not met");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const last_selection = selections[selections.length - 1];
|
const last_selection = selections[selections.length - 1];
|
||||||
if (
|
if (
|
||||||
direction === "Right" &&
|
direction === "Right" &&
|
||||||
|
|
@ -153,10 +164,7 @@
|
||||||
set_selections([
|
set_selections([
|
||||||
{
|
{
|
||||||
region: "main",
|
region: "main",
|
||||||
coords: [
|
coords: [last_selection.coords[0] + 1, last_selection.coords[1]],
|
||||||
last_selection.coords[0] + 1,
|
|
||||||
last_selection.coords[1],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -173,10 +181,7 @@
|
||||||
set_selections([
|
set_selections([
|
||||||
{
|
{
|
||||||
region: "inserter",
|
region: "inserter",
|
||||||
coords: [
|
coords: [last_selection.coords[0] + 1, last_selection.coords[1]],
|
||||||
last_selection.coords[0] + 1,
|
|
||||||
last_selection.coords[1],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -187,10 +192,7 @@
|
||||||
set_selections([
|
set_selections([
|
||||||
{
|
{
|
||||||
region: "main",
|
region: "main",
|
||||||
coords: [
|
coords: [last_selection.coords[0] - 1, last_selection.coords[1]],
|
||||||
last_selection.coords[0] - 1,
|
|
||||||
last_selection.coords[1],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -199,10 +201,7 @@
|
||||||
set_selections([
|
set_selections([
|
||||||
{
|
{
|
||||||
region: "inserter",
|
region: "inserter",
|
||||||
coords: [
|
coords: [last_selection.coords[0] - 1, last_selection.coords[1]],
|
||||||
last_selection.coords[0] - 1,
|
|
||||||
last_selection.coords[1],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -217,20 +216,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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,66 +232,71 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
|
||||||
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",
|
method: "post",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
column: field.field.name,
|
cells: delta.cells.map((cell) => ({
|
||||||
pkeys: pkey,
|
pkey: JSON.parse(lazy_data_scoped.rows[cell.coords[0]].key as string),
|
||||||
value: parsed,
|
column: lazy_data_scoped.fields[cell.coords[1]].field.name,
|
||||||
|
value: cell.value_updated,
|
||||||
|
})),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (resp.status >= 200 && resp.status < 300) {
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
committed_changes.push([
|
deltas.commit_pending = deltas.commit_pending.filter((x) => x !== delta);
|
||||||
{
|
deltas.committed = [...deltas.committed, delta];
|
||||||
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 {
|
} else {
|
||||||
// TODO display feedback to user
|
// TODO display feedback to user
|
||||||
console.error(resp);
|
console.error(resp);
|
||||||
console.error(await resp.text());
|
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
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel_edit() {
|
function cancel_edit() {
|
||||||
selections.forEach(({ coords, original_value, region }) => {
|
selections.forEach(({ coords, original_value, region }) => {
|
||||||
|
|
@ -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,26 +345,15 @@
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} 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];
|
||||||
|
|
@ -375,12 +362,10 @@
|
||||||
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];
|
||||||
|
|
@ -389,29 +374,6 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Initial API Fetch -------- //
|
// -------- Initial API Fetch -------- //
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue