Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 185 additions & 46 deletions src/lib/components/DataTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ import FilterX from "@lucide/svelte/icons/filter-x";
buildDeleteStatements,
buildInsertStatements,
} from "$lib/dml-preview.js";
import { formatSql } from "$lib/format-sql.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import * as Select from "$lib/components/ui/select/index.js";
import ShikiBlock from "./ShikiBlock.svelte";
import {
savePendingChanges,
Expand Down Expand Up @@ -218,6 +220,13 @@ import FilterX from "@lucide/svelte/icons/filter-x";
onfetchrelatedrows = /** @type {(detail: any) => Promise<{ columns: any[], rows: any[], error?: string }>} */ (async () => ({ columns: [], rows: [] })),
/** Called when the user confirms the new row draft. Receives the validated values. */
oninsertrow = /** @type {(values: Record<string, unknown>) => Promise<void>} */ (async () => {}),
/**
* Execute raw SQL the user hand-edited in the DML preview, then refetch. Only
* invoked when the previewed SQL was actually changed — the unedited path still
* runs through the structured per-cell writes (`onsave`/`ondelete`/`oninsertrow`).
* @type {(sql: string) => Promise<void>}
*/
onexecutesql = /** @type {(sql: string) => Promise<void>} */ (async () => {}),
/** True while the insert is in flight — disables the draft row inputs. */
insertSaving = false,
/** Assigned by this component so the parent can trigger beginInsertRow(). */
Expand Down Expand Up @@ -287,6 +296,12 @@ import FilterX from "@lucide/svelte/icons/filter-x";
let dmlPreview = $state(null);
/** True while the confirmed write is in flight. */
let dmlPreviewRunning = $state(false);
/** Prettified SQL shown in the (editable) preview editor. Bound to the editor. */
let dmlEditedSql = $state("");
/** The pristine prettified SQL, to detect whether the user edited it. */
let dmlOriginalSql = $state("");
/** True once the user has changed the previewed SQL — switches Apply to raw exec. */
const dmlWasEdited = $derived(dmlEditedSql.trim() !== dmlOriginalSql.trim());

/** @type {import('$lib/dml-preview.js').DmlContext} */
const dmlContext = $derived({ dialect, schema, table: tableName, columns, primaryKey });
Expand All @@ -297,16 +312,35 @@ import FilterX from "@lucide/svelte/icons/filter-x";
* @param {NonNullable<typeof dmlPreview>} config
*/
function requestWrite(config) {
if ($appPreviewDml) dmlPreview = config;
else void config.run();
if ($appPreviewDml) {
dmlPreview = config;
// Prettify the generated statements for a readable, editable preview.
dmlOriginalSql = formatSql(config.statements.join("\n"));
dmlEditedSql = dmlOriginalSql;
} else {
void config.run();
}
}

/** Run the previewed write, then close the dialog. */
async function confirmDmlPreview() {
if (!dmlPreview || dmlPreviewRunning) return;
dmlPreviewRunning = true;
try {
await dmlPreview.run();
if (dmlWasEdited) {
// The user hand-edited the SQL — run it verbatim (as raw statements) and
// refetch. Arbitrary edits can't be mapped back to the staged per-cell
// model, so drop all staged edits/deletes and the inline insert draft.
await onexecutesql(dmlEditedSql.trim());
pendingEdits = new Map();
pendingDeletes = new Set();
pastEdits = [];
futureEdits = [];
savePendingChanges(_persistKey, pendingEdits, pendingDeletes);
cancelNewRow();
} else {
await dmlPreview.run();
}
dmlPreview = null;
} finally {
dmlPreviewRunning = false;
Expand Down Expand Up @@ -413,6 +447,8 @@ import FilterX from "@lucide/svelte/icons/filter-x";
let tableContainer = $state(/** @type {HTMLDivElement | null} */ (null));
/** Whether to select-all text when the edit input is focused. */
let selectOnEditFocus = $state(true);
/** Whether the enum cell-editor dropdown is open (auto-opens on edit). */
let enumEditorOpen = $state(false);
/** Raw cell value before the current edit started (for undo tracking). */
let lastEditOriginalValue = $state(/** @type {unknown} */ (undefined));
/**
Expand Down Expand Up @@ -836,11 +872,15 @@ import FilterX from "@lucide/svelte/icons/filter-x";
draft: initialChar !== undefined ? initialChar : original,
original,
};
// Enum columns edit via a dropdown — open it immediately so a single
// interaction (double-click / Enter) reveals the choices.
enumEditorOpen = !!getColumnEnumValues(col);
}

function cancelEdit() {
if (!editingCell) return;
editingCell = null;
enumEditorOpen = false;
tick().then(() => tableContainer?.focus({ preventScroll: true }));
}

Expand Down Expand Up @@ -1025,21 +1065,41 @@ import FilterX from "@lucide/svelte/icons/filter-x";
}
}

/** Open the DML preview for all staged changes (edits + deletes); confirm flushes them. */
/**
* Validated values for the inline new-row draft, if the user has typed anything
* into it and it passes validation; otherwise null. Lets the combined "Review
* changes" flow include the pending INSERT instead of silently dropping it.
* @returns {Record<string, unknown> | null}
*/
function pendingInsertValues() {
if (!newRowDrafts) return null;
const hasAny = Object.values(newRowDrafts).some((v) => v !== "" && v != null);
if (!hasAny) return null;
const editableCols = columns.filter((c) => isEditableType(c.dataType ?? c.data_type ?? ""));
const built = buildInsertPayload(editableCols, primaryKey, newRowDrafts);
return built.ok ? /** @type {Record<string, unknown>} */ (built.values) : null;
}

/** Open the DML preview for all staged changes (edits + deletes + a pending insert). */
function applyPendingEdits() {
if (!hasPendingChanges || saving) return;
const insertValues = pendingInsertValues();
if ((!hasPendingChanges && !insertValues) || saving) return;
// A row staged for deletion doesn't need its cell updates written first.
const editEntries = [...pendingEdits.values()].filter((e) => !pendingDeletes.has(e.rowIdx));
const deleteIndices = [...pendingDeletes].sort((a, b) => a - b);
// Order: updates, deletes, then the insert. Inserting last keeps the existing
// row indices (which the updates/deletes address) stable during execution.
const statements = [
...buildUpdateStatements(editEntries, rows, dmlContext),
...(deleteIndices.length ? buildDeleteStatements(deleteIndices, rows, dmlContext) : []),
...(insertValues ? buildInsertStatements(insertValues, dmlContext) : []),
];
const parts = [];
if (editEntries.length) parts.push(`${editEntries.length} cell${editEntries.length === 1 ? "" : "s"} updated`);
if (deleteIndices.length) parts.push(`${deleteIndices.length} row${deleteIndices.length === 1 ? "" : "s"} deleted`);
if (insertValues) parts.push("1 row inserted");
requestWrite({
kind: deleteIndices.length ? "delete" : "update",
kind: deleteIndices.length ? "delete" : insertValues && !editEntries.length ? "insert" : "update",
title: "Review changes",
description: `${parts.join(", ")}.${deleteIndices.length ? " Deletions cannot be undone." : ""}`,
statements,
Expand All @@ -1049,9 +1109,10 @@ import FilterX from "@lucide/svelte/icons/filter-x";
});
}

/** Flush all staged edits and deletes to the database. */
/** Flush all staged edits and deletes (and a pending insert) to the database. */
async function executePendingChanges() {
if (!hasPendingChanges || saving) return;
const insertValues = pendingInsertValues();
if ((!hasPendingChanges && !insertValues) || saving) return;
// Edits first (skipping rows about to be deleted), then the deletes.
const entries = [...pendingEdits.values()].filter((e) => !pendingDeletes.has(e.rowIdx));
/** @type {typeof entries} */
Expand Down Expand Up @@ -1079,6 +1140,17 @@ import FilterX from "@lucide/svelte/icons/filter-x";
}
}

// Insert last so the row indices addressed by the updates/deletes above stay
// valid throughout. oninsertrow surfaces its own toast + refetch.
if (insertValues) {
try {
await oninsertrow(insertValues);
cancelNewRow();
} catch {
// error toast already shown by oninsertrow
}
}

// Keep only the edits that failed so the user can retry / reset them — unless
// deletes ran, which splice `rows` and invalidate the failed edits' row indices;
// in that case drop them so a retry can't target the wrong row.
Expand Down Expand Up @@ -4222,30 +4294,50 @@ import FilterX from "@lucide/svelte/icons/filter-x";
{#if isAuto}
<span class="select-none font-mono text-ui-sm text-muted-foreground/35 italic">auto</span>
{:else if enumValues}
<select
data-new-row-input={col.name}
disabled={insertSaving}
class="w-full bg-transparent font-mono text-ui-sm text-foreground outline-none disabled:opacity-50"
<Select.Root
type="single"
value={newRowDrafts[col.name] ?? ''}
onchange={(e) => setNewRowDraft(col.name, e.currentTarget.value)}
onfocus={() => (newRowFocusCol = col.name)}
onValueChange={(v) => setNewRowDraft(col.name, v ?? '')}
>
<option value="">{col.nullable ? 'NULL / default' : 'Select…'}</option>
{#each enumValues as opt (opt)}<option value={opt}>{opt}</option>{/each}
</select>
<Select.Trigger
data-new-row-input={col.name}
disabled={insertSaving}
onfocus={() => (newRowFocusCol = col.name)}
class="h-7 w-full min-w-0 rounded-md border-0 bg-transparent px-1 py-0 font-mono text-ui-sm text-foreground shadow-none focus-visible:ring-0 disabled:opacity-50"
>
<span data-slot="select-value" class={cn('truncate', !newRowDrafts[col.name] && 'text-muted-foreground/50')}>
{newRowDrafts[col.name] || (col.nullable ? 'NULL / default' : 'Select…')}
</span>
</Select.Trigger>
<Select.Content align="start" sideOffset={4} class="max-h-64 p-1">
<Select.Item value="" label={col.nullable ? 'NULL / default' : 'Select…'} class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm text-muted-foreground">{col.nullable ? 'NULL / default' : 'Select…'}</Select.Item>
{#each enumValues as opt (opt)}
<Select.Item value={opt} label={opt} class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm">{opt}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else if isBoolean}
<select
data-new-row-input={col.name}
disabled={insertSaving}
class="w-full bg-transparent font-mono text-ui-sm text-foreground outline-none disabled:opacity-50"
<Select.Root
type="single"
value={newRowDrafts[col.name] ?? ''}
onchange={(e) => setNewRowDraft(col.name, e.currentTarget.value)}
onfocus={() => (newRowFocusCol = col.name)}
onValueChange={(v) => setNewRowDraft(col.name, v ?? '')}
>
<option value="">{col.nullable ? 'NULL / default' : 'Default'}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
<Select.Trigger
data-new-row-input={col.name}
disabled={insertSaving}
onfocus={() => (newRowFocusCol = col.name)}
class="h-7 w-full min-w-0 rounded-md border-0 bg-transparent px-1 py-0 font-mono text-ui-sm text-foreground shadow-none focus-visible:ring-0 disabled:opacity-50"
>
<span data-slot="select-value" class={cn('truncate', !newRowDrafts[col.name] && 'text-muted-foreground/50')}>
{newRowDrafts[col.name] || (col.nullable ? 'NULL / default' : 'Default')}
</span>
</Select.Trigger>
<Select.Content align="start" sideOffset={4} class="min-w-[8rem] p-1">
<Select.Item value="" label={col.nullable ? 'NULL / default' : 'Default'} class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm text-muted-foreground">{col.nullable ? 'NULL / default' : 'Default'}</Select.Item>
<Select.Item value="true" label="true" class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm">true</Select.Item>
<Select.Item value="false" label="false" class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm">false</Select.Item>
</Select.Content>
</Select.Root>
{:else if isDateTime}
<DateTimePicker
colName={col.name}
Expand Down Expand Up @@ -4368,22 +4460,45 @@ import FilterX from "@lucide/svelte/icons/filter-x";
style="top:{editOverlay.top}px; left:{editOverlay.left}px; width:{editOverlay.width}px; height:{editOverlay.height}px"
>
{#if eEnum}
<select
bind:this={editInput}
bind:value={editingCell.draft}
disabled={saving}
aria-label="Edit {ecol?.name ?? 'cell'}"
class="box-border block h-full w-full min-w-0 max-w-full cursor-pointer appearance-none border-0 bg-transparent px-3 font-mono text-ui-sm text-foreground outline-none"
onclick={(e) => e.stopPropagation()}
onkeydown={handleEditKeydown}
onchange={() => void commitEdit()}
<!-- Themed, portaled dropdown (bits-ui) — replaces the native
<select>, whose OS popup was unstyled and broke on Linux/
WebKitGTK. Auto-opens on edit; picking a value commits. -->
<Select.Root
type="single"
value={editingCell.draft}
open={enumEditorOpen}
onOpenChange={(o) => {
enumEditorOpen = o;
// Closed without a pick (Escape / click-away) → cancel edit.
if (!o && editingCell) cancelEdit();
}}
onValueChange={(v) => {
if (!editingCell) return;
editingCell.draft = v ?? '';
void commitEdit();
}}
>
{#if eNullable}<option value="">NULL</option>{/if}
{#if editingCell.original && !eEnum.includes(editingCell.original)}
<option value={editingCell.original}>{editingCell.original}</option>
{/if}
{#each eEnum as option (option)}<option value={option}>{option}</option>{/each}
</select>
<Select.Trigger
bind:ref={editInput}
aria-label="Edit {ecol?.name ?? 'cell'}"
class="box-border h-full w-full min-w-0 max-w-full rounded-none border-0 bg-transparent px-3 py-0 font-mono text-ui-sm text-foreground shadow-none focus-visible:ring-0"
>
<span data-slot="select-value" class="truncate">
{editingCell.draft || (eNullable ? 'NULL' : 'Select…')}
</span>
</Select.Trigger>
<Select.Content align="start" sideOffset={2} class="max-h-64 min-w-[var(--bits-select-anchor-width)] p-1">
{#if eNullable}
<Select.Item value="" label="NULL" class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm text-muted-foreground">NULL</Select.Item>
{/if}
{#if editingCell.original && !eEnum.includes(editingCell.original)}
<Select.Item value={editingCell.original} label={editingCell.original} class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm">{editingCell.original}</Select.Item>
{/if}
{#each eEnum as option (option)}
<Select.Item value={option} label={option} class="py-1.5 pl-2.5 pr-8 font-mono text-ui-sm">{option}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else if isBooleanType(eType)}
{@const isOn = editingCell.draft === "true"}
{@const isNull = eNullable && editingCell.draft !== "true" && editingCell.draft !== "false"}
Expand Down Expand Up @@ -4787,17 +4902,41 @@ import FilterX from "@lucide/svelte/icons/filter-x";
</Dialog.Description>
</Dialog.Header>

<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<span class="text-ui-2xs font-medium uppercase tracking-wide text-muted-foreground/50">
SQL to run{dmlPreview.statements.length > 1 ? ` · ${dmlPreview.statements.length} statements` : ''}
</span>
{#if dmlWasEdited}
<button
type="button"
onclick={() => { dmlEditedSql = dmlOriginalSql }}
disabled={dmlPreviewRunning}
class="inline-flex items-center gap-1 text-ui-2xs text-muted-foreground/60 hover:text-foreground disabled:opacity-50"
title="Discard your edits and restore the generated SQL"
>
<RotateCcw class="size-3" />
Reset SQL
</button>
{/if}
</div>

<!-- Syntax-highlighted, wrapping, scrollable SQL preview (hover to copy). -->
<div class="flex max-h-[45vh] min-h-0 flex-col overflow-hidden rounded-lg border border-border/50">
<ShikiBlock code={dmlPreview.statements.join('\n')} lang="sql" />
<!-- Editable, prettified SQL. Monaco loads lazily (kept out of the plain
table-browsing bundle); Shiki renders an instant highlighted preview
while it mounts. Editing the SQL switches Apply to run it verbatim. -->
<div class="flex h-[min(46vh,380px)] min-h-[160px] flex-col overflow-hidden rounded-lg border border-border/50 bg-background/40">
{#await import('./SqlEditor.svelte')}
<ShikiBlock code={dmlEditedSql} lang="sql" />
{:then { default: SqlEditor }}
<SqlEditor bind:value={dmlEditedSql} onmodenter={confirmDmlPreview} class="rounded-lg" />
{/await}
</div>

{#if dmlWasEdited}
<p class="text-ui-2xs text-amber-500/80">
You edited the SQL — Apply will run it exactly as written.
</p>
{/if}

<Dialog.Footer class="gap-2 sm:justify-end">
<button
type="button"
Expand Down
Loading
Loading