1
0
Fork 0
forked from 2sys/phonograph
phonograph/svelte/src/table-viewer.webc.svelte

568 lines
17 KiB
Svelte
Raw Normal View History

2025-09-08 15:56:57 -07:00
<svelte:options
customElement={{
props: {
columns: { type: "Array" },
},
shadow: "none",
tag: "table-viewer",
}}
/>
2025-08-10 14:32:15 -07:00
<script lang="ts">
2025-08-13 18:52:37 -07:00
import { z } from "zod";
2025-08-10 14:32:15 -07:00
2025-09-08 15:56:57 -07:00
import icon_cloud_arrow_up from "../assets/heroicons/20/solid/cloud-arrow-up.svg?raw";
import icon_cube_transparent from "../assets/heroicons/20/solid/cube-transparent.svg?raw";
import icon_exclamation_circle from "../assets/heroicons/20/solid/exclamation-circle.svg?raw";
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";
2025-08-10 14:32:15 -07:00
import {
type Coords,
type Row,
2025-08-13 18:52:37 -07:00
type FieldInfo,
2025-08-10 14:32:15 -07:00
coords_eq,
2025-08-13 18:52:37 -07:00
field_info_schema,
2025-08-10 14:32:15 -07:00
} from "./field.svelte";
import FieldAdder from "./field-adder.svelte";
2025-08-13 18:52:37 -07:00
import FieldHeader from "./field-header.svelte";
import { get_empty_datum_for } from "./presentation.svelte";
2025-09-08 15:56:57 -07:00
type Props = {
columns?: {
name: string;
regtype: string;
}[];
2025-09-08 15:56:57 -07:00
};
let { columns = [] }: Props = $props();
2025-08-10 14:32:15 -07:00
2025-10-16 06:40:11 +00:00
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;
2025-08-10 14:32:15 -07:00
};
2025-10-16 06:40:11 +00:00
type Delta = {
cells: CellDelta[];
};
2025-08-10 14:32:15 -07:00
type LazyData = {
rows: Row[];
2025-08-13 18:52:37 -07:00
fields: FieldInfo[];
2025-08-10 14:32:15 -07:00
};
type Selection = {
region: "main" | "inserter";
coords: Coords;
original_value: Datum;
2025-08-10 14:32:15 -07:00
};
let selections = $state<Selection[]>([]);
2025-10-16 06:40:11 +00:00
// 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>();
2025-08-10 14:32:15 -07:00
let table_element = $state<HTMLDivElement | undefined>();
let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>();
// -------- Helper Functions -------- //
function arrow_key_direction(
key: string,
): "Down" | "Left" | "Right" | "Up" | undefined {
if (key === "ArrowDown") {
return "Down";
} else if (key === "ArrowLeft") {
return "Left";
} else if (key === "ArrowRight") {
return "Right";
} else if (key === "ArrowUp") {
return "Up";
} else {
return undefined;
}
}
// -------- Updates and Effects -------- //
function set_selections(arr: Omit<Selection, "original_value">[]) {
selections = arr.map((sel) => {
let cell_data: Datum | undefined;
2025-08-10 14:32:15 -07:00
if (sel.region === "main") {
cell_data = lazy_data?.rows[sel.coords[0]].data[sel.coords[1]];
} else if (sel.region === "inserter") {
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
} else {
throw new Error("invalid region");
}
return {
...sel,
original_value: cell_data!,
};
});
if (arr.length === 1) {
const [sel] = arr;
let cell_data: Datum | undefined;
2025-08-10 14:32:15 -07:00
if (sel.region === "main") {
cell_data = lazy_data?.rows[sel.coords[0]].data[sel.coords[1]];
} else if (sel.region === "inserter") {
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
}
2025-10-16 06:40:11 +00:00
editor_value = cell_data;
2025-08-10 14:32:15 -07:00
} else {
2025-10-16 06:40:11 +00:00
editor_value = undefined;
2025-08-10 14:32:15 -07:00
}
}
2025-10-16 06:40:11 +00:00
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]],
},
]);
2025-08-10 14:32:15 -07:00
}
2025-10-16 06:40:11 +00:00
} 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]],
},
]);
2025-08-10 14:32:15 -07:00
}
}
}
}
function try_sync_edit_to_cells() {
2025-10-16 06:40:11 +00:00
if (lazy_data && selections.length === 1) {
2025-08-10 14:32:15 -07:00
const [sel] = selections;
2025-10-16 06:40:11 +00:00
if (editor_value !== undefined) {
2025-08-10 14:32:15 -07:00
if (sel.region === "main") {
2025-10-16 06:40:11 +00:00
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
2025-08-10 14:32:15 -07:00
} else if (sel.region === "inserter") {
2025-10-16 06:40:11 +00:00
inserter_rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
2025-08-10 14:32:15 -07:00
} else {
throw new Error("Unknown region");
}
}
}
}
2025-10-16 06:40:11 +00:00
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,
}));
}
2025-08-10 14:32:15 -07:00
}
}
2025-10-16 06:40:11 +00:00
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);
}
2025-08-10 14:32:15 -07:00
}
function cancel_edit() {
selections.forEach(({ coords, original_value, region }) => {
if (region === "main") {
if (lazy_data) {
lazy_data.rows[coords[0]].data[coords[1]] = original_value;
}
} else if (region === "inserter") {
inserter_rows[coords[0]].data[coords[1]] = original_value;
} else {
throw new Error("Unknown region");
}
});
// Reset editor input value
set_selections(selections);
table_element?.focus();
}
// -------- Event Handlers: Both Tables -------- //
function handle_table_keydown(ev: KeyboardEvent) {
2025-08-13 18:52:37 -07:00
if (lazy_data) {
const arrow_direction = arrow_key_direction(ev.key);
if (arrow_direction) {
2025-10-16 06:40:11 +00:00
move_selection(arrow_direction);
2025-08-13 18:52:37 -07:00
ev.preventDefault();
}
if (ev.key === "Enter") {
if (ev.shiftKey) {
if (selections[0]?.region === "main") {
set_selections([
{
region: "inserter",
coords: [0, selections[0]?.coords[1] ?? 0],
},
]);
} else {
inserter_rows = [
...inserter_rows,
{
key: inserter_rows.length,
2025-09-08 15:56:57 -07:00
data: lazy_data.fields.map(({ field: { presentation } }) =>
get_empty_datum_for(presentation),
2025-08-13 18:52:37 -07:00
),
},
];
}
} else {
2025-10-16 06:40:11 +00:00
datum_editor?.focus();
2025-08-13 18:52:37 -07:00
}
}
2025-08-10 14:32:15 -07:00
}
}
// -------- Event Handlers: Main Table -------- //
function handle_main_cell_click(ev: MouseEvent, coords: Coords) {
2025-10-16 06:40:11 +00:00
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "main", coords }]);
2025-08-10 14:32:15 -07:00
}
}
// -------- Event Handlers: Inserter Table -------- //
function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) {
2025-10-16 06:40:11 +00:00
if (ev.metaKey || ev.ctrlKey) {
// TODO
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
// editor_input_value = "";
} else {
set_selections([{ region: "inserter", coords }]);
2025-08-10 14:32:15 -07:00
}
}
// -------- Initial API Fetch -------- //
(async function () {
2025-08-13 18:52:37 -07:00
const get_data_response_schema = z.object({
rows: z.array(
z.object({
pkey: z.string(),
data: z.array(datum_schema),
2025-08-13 18:52:37 -07:00
}),
),
fields: z.array(field_info_schema),
});
2025-08-10 14:32:15 -07:00
const resp = await fetch("get-data");
2025-08-13 18:52:37 -07:00
const body = get_data_response_schema.parse(await resp.json());
2025-08-10 14:32:15 -07:00
lazy_data = {
fields: body.fields,
2025-08-13 18:52:37 -07:00
rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
2025-08-10 14:32:15 -07:00
};
inserter_rows = [
{
key: 0,
2025-09-08 15:56:57 -07:00
data: body.fields.map(({ field: { presentation } }) =>
get_empty_datum_for(presentation),
2025-08-13 18:52:37 -07:00
),
2025-08-10 14:32:15 -07:00
},
];
})().catch(console.error);
2025-10-16 06:40:11 +00:00
setInterval(tick_delta_queue, 500);
2025-08-10 14:32:15 -07:00
</script>
{#snippet table_region({
region_name,
rows,
on_cell_click,
}: {
2025-08-13 18:52:37 -07:00
region_name: "main" | "inserter";
2025-08-10 14:32:15 -07:00
rows: Row[];
on_cell_click(ev: MouseEvent, coords: Coords): void;
})}
{#if lazy_data}
{#each rows as row, row_index}
<div class="lens-table__row" role="row">
{#each lazy_data.fields as field, field_index}
{@const cell_data = row.data[field_index]}
{@const cell_coords: Coords = [row_index, field_index]}
{@const cell_selected = selections.some(
(sel) =>
sel.region === region_name && coords_eq(sel.coords, cell_coords),
)}
2025-09-08 15:56:57 -07:00
{@const null_value_html =
2025-08-13 18:52:37 -07:00
region_name === "inserter" && field.has_default
2025-09-08 15:56:57 -07:00
? icon_sparkles
: icon_cube_transparent}
2025-08-13 18:52:37 -07:00
{@const invalid_value =
field.not_null && !field.has_default && cell_data.c === undefined}
2025-08-10 14:32:15 -07:00
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
aria-colindex={field_index}
aria-rowindex={row_index}
aria-selected={cell_selected}
2025-08-13 18:52:37 -07:00
class="lens-table__cell"
2025-08-10 14:32:15 -07:00
onmousedown={(ev) => on_cell_click(ev, cell_coords)}
2025-10-16 06:40:11 +00:00
ondblclick={() => {
datum_editor?.focus();
}}
2025-08-10 14:32:15 -07:00
role="gridcell"
style:width={`${field.field.table_width_px}px`}
2025-08-10 14:32:15 -07:00
tabindex="-1"
>
2025-08-13 18:52:37 -07:00
<div
2025-10-16 06:40:11 +00:00
class="lens-cell__container"
class:lens-cell__container--selected={cell_selected}
2025-08-13 18:52:37 -07:00
>
{#if cell_data.t === "Text"}
<div
2025-10-16 06:40:11 +00:00
class="lens-cell__content lens-cell__content--text"
class:lens-cell__content--null={cell_data.c === undefined}
2025-08-13 18:52:37 -07:00
>
2025-09-08 15:56:57 -07:00
{#if cell_data.c === undefined}
{@html null_value_html}
{:else}
{cell_data.c}
{/if}
2025-08-13 18:52:37 -07:00
</div>
{:else if cell_data.t === "Uuid"}
<div
2025-10-16 06:40:11 +00:00
class="lens-cell__content lens-cell__content--uuid"
class:lens-cell__content--null={cell_data.c === undefined}
2025-08-13 18:52:37 -07:00
>
2025-09-08 15:56:57 -07:00
{#if cell_data.c === undefined}
{@html null_value_html}
{:else}
{cell_data.c}
{/if}
2025-08-13 18:52:37 -07:00
</div>
{:else}
2025-10-16 06:40:11 +00:00
<div class="lens-cell__content lens-cell__content--unknown">
2025-08-13 18:52:37 -07:00
<div>UNKNOWN</div>
</div>
{/if}
{#if invalid_value}
<div class="lens-cell__notice">
{@html icon_exclamation_circle}
</div>
{/if}
</div>
2025-08-10 14:32:15 -07:00
</div>
{/each}
</div>
{/each}
{/if}
{/snippet}
<div class="lens-grid">
{#if lazy_data}
<div
bind:this={table_element}
class="lens-table"
2025-10-16 06:40:11 +00:00
onfocus={() => {
try_queue_delta();
}}
2025-08-10 14:32:15 -07:00
onkeydown={handle_table_keydown}
role="grid"
tabindex="0"
>
<div class={["lens-table__headers"]}>
2025-09-08 15:56:57 -07:00
{#each lazy_data.fields as _, field_index}
<FieldHeader
bind:field={lazy_data.fields[field_index]}
index={field_index}
/>
2025-08-10 14:32:15 -07:00
{/each}
2025-09-08 15:56:57 -07:00
<div class="lens-table__header-actions">
<FieldAdder {columns}></FieldAdder>
2025-09-08 15:56:57 -07:00
</div>
2025-08-10 14:32:15 -07:00
</div>
<div class="lens-table__main">
{@render table_region({
region_name: "main",
rows: lazy_data.rows,
on_cell_click: handle_main_cell_click,
})}
</div>
2025-08-13 18:52:37 -07:00
<form method="post" action="insert">
<div class="lens-table__inserter">
<div class="lens-inserter__rows">
{@render table_region({
region_name: "inserter",
rows: inserter_rows,
on_cell_click: handle_inserter_cell_click,
})}
</div>
<button
class="lens-inserter__submit"
onkeydown={(ev) => {
// Prevent keypress (e.g. pressing Enter on the button to submit
// it) from triggering a table interaction.
ev.stopPropagation();
}}
type="submit"
>
{@html icon_cloud_arrow_up}
</button>
</div>
{#each inserter_rows as row}
{#each lazy_data.fields as field, field_index}
<input
type="hidden"
name={field.field.name}
value={JSON.stringify(row.data[field_index])}
/>
{/each}
{/each}
</form>
2025-08-10 14:32:15 -07:00
</div>
2025-10-16 06:40:11 +00:00
<div class="table-viewer__datum-editor">
{#if selections.length === 1}
<DatumEditor
2025-10-16 06:40:11 +00:00
bind:this={datum_editor}
bind:value={editor_value}
2025-08-24 23:24:01 -07:00
field_info={lazy_data.fields[selections[0].coords[1]]}
2025-10-16 06:40:11 +00:00
on_change={() => {
try_sync_edit_to_cells();
}}
2025-08-24 23:24:01 -07:00
/>
{/if}
2025-08-10 14:32:15 -07:00
</div>
{/if}
</div>