lopata 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/package.json +51 -0
- package/runtime/bindings/ai.ts +132 -0
- package/runtime/bindings/analytics-engine.ts +96 -0
- package/runtime/bindings/browser.ts +64 -0
- package/runtime/bindings/cache.ts +179 -0
- package/runtime/bindings/cf-streams.ts +56 -0
- package/runtime/bindings/container-docker.ts +225 -0
- package/runtime/bindings/container.ts +662 -0
- package/runtime/bindings/crypto-extras.ts +89 -0
- package/runtime/bindings/d1.ts +315 -0
- package/runtime/bindings/do-executor-inprocess.ts +140 -0
- package/runtime/bindings/do-executor-worker.ts +368 -0
- package/runtime/bindings/do-executor.ts +45 -0
- package/runtime/bindings/do-websocket-bridge.ts +70 -0
- package/runtime/bindings/do-worker-entry.ts +220 -0
- package/runtime/bindings/do-worker-env.ts +74 -0
- package/runtime/bindings/durable-object.ts +992 -0
- package/runtime/bindings/email.ts +180 -0
- package/runtime/bindings/html-rewriter.ts +84 -0
- package/runtime/bindings/hyperdrive.ts +130 -0
- package/runtime/bindings/images.ts +381 -0
- package/runtime/bindings/kv.ts +359 -0
- package/runtime/bindings/queue.ts +507 -0
- package/runtime/bindings/r2.ts +759 -0
- package/runtime/bindings/rpc-stub.ts +267 -0
- package/runtime/bindings/scheduled.ts +172 -0
- package/runtime/bindings/service-binding.ts +217 -0
- package/runtime/bindings/static-assets.ts +481 -0
- package/runtime/bindings/websocket-pair.ts +182 -0
- package/runtime/bindings/workflow.ts +858 -0
- package/runtime/bunflare-config.ts +56 -0
- package/runtime/cli/cache.ts +39 -0
- package/runtime/cli/context.ts +105 -0
- package/runtime/cli/d1.ts +163 -0
- package/runtime/cli/dev.ts +392 -0
- package/runtime/cli/kv.ts +84 -0
- package/runtime/cli/queues.ts +109 -0
- package/runtime/cli/r2.ts +140 -0
- package/runtime/cli/traces.ts +251 -0
- package/runtime/cli.ts +102 -0
- package/runtime/config.ts +148 -0
- package/runtime/d1-migrate.ts +37 -0
- package/runtime/dashboard/api.ts +174 -0
- package/runtime/dashboard/app.tsx +220 -0
- package/runtime/dashboard/components/breadcrumb.tsx +16 -0
- package/runtime/dashboard/components/buttons.tsx +13 -0
- package/runtime/dashboard/components/code-block.tsx +5 -0
- package/runtime/dashboard/components/detail-field.tsx +8 -0
- package/runtime/dashboard/components/empty-state.tsx +8 -0
- package/runtime/dashboard/components/filter-input.tsx +11 -0
- package/runtime/dashboard/components/index.ts +16 -0
- package/runtime/dashboard/components/key-value-table.tsx +23 -0
- package/runtime/dashboard/components/modal.tsx +23 -0
- package/runtime/dashboard/components/page-header.tsx +11 -0
- package/runtime/dashboard/components/pill-button.tsx +14 -0
- package/runtime/dashboard/components/refresh-button.tsx +7 -0
- package/runtime/dashboard/components/service-info.tsx +45 -0
- package/runtime/dashboard/components/status-badge.tsx +7 -0
- package/runtime/dashboard/components/table-link.tsx +5 -0
- package/runtime/dashboard/components/table.tsx +26 -0
- package/runtime/dashboard/components.tsx +19 -0
- package/runtime/dashboard/index.html +23 -0
- package/runtime/dashboard/lib.ts +45 -0
- package/runtime/dashboard/rpc/client.ts +20 -0
- package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
- package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
- package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
- package/runtime/dashboard/rpc/handlers/config.ts +137 -0
- package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
- package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
- package/runtime/dashboard/rpc/handlers/do.ts +117 -0
- package/runtime/dashboard/rpc/handlers/email.ts +82 -0
- package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
- package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
- package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
- package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
- package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
- package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
- package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
- package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
- package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
- package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
- package/runtime/dashboard/rpc/hooks.ts +132 -0
- package/runtime/dashboard/rpc/server.ts +70 -0
- package/runtime/dashboard/rpc/types.ts +396 -0
- package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
- package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
- package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
- package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
- package/runtime/dashboard/sql-browser/hooks.ts +137 -0
- package/runtime/dashboard/sql-browser/index.ts +4 -0
- package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
- package/runtime/dashboard/sql-browser/modals.tsx +116 -0
- package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
- package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
- package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
- package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
- package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
- package/runtime/dashboard/sql-browser/types.ts +61 -0
- package/runtime/dashboard/sql-browser/utils.ts +167 -0
- package/runtime/dashboard/style.css +177 -0
- package/runtime/dashboard/views/ai.tsx +152 -0
- package/runtime/dashboard/views/analytics-engine.tsx +169 -0
- package/runtime/dashboard/views/cache.tsx +93 -0
- package/runtime/dashboard/views/containers.tsx +197 -0
- package/runtime/dashboard/views/d1.tsx +81 -0
- package/runtime/dashboard/views/do.tsx +168 -0
- package/runtime/dashboard/views/email.tsx +235 -0
- package/runtime/dashboard/views/errors.tsx +558 -0
- package/runtime/dashboard/views/home.tsx +287 -0
- package/runtime/dashboard/views/kv.tsx +273 -0
- package/runtime/dashboard/views/queue.tsx +193 -0
- package/runtime/dashboard/views/r2.tsx +202 -0
- package/runtime/dashboard/views/scheduled.tsx +89 -0
- package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
- package/runtime/dashboard/views/traces.tsx +768 -0
- package/runtime/dashboard/views/workers.tsx +55 -0
- package/runtime/dashboard/views/workflows.tsx +473 -0
- package/runtime/db.ts +258 -0
- package/runtime/env.ts +362 -0
- package/runtime/error-page/app.tsx +394 -0
- package/runtime/error-page/build.ts +269 -0
- package/runtime/error-page/index.html +16 -0
- package/runtime/error-page/style.css +31 -0
- package/runtime/execution-context.ts +18 -0
- package/runtime/file-watcher.ts +57 -0
- package/runtime/generation-manager.ts +230 -0
- package/runtime/generation.ts +411 -0
- package/runtime/plugin.ts +292 -0
- package/runtime/request-cf.ts +28 -0
- package/runtime/rpc-validate.ts +154 -0
- package/runtime/tracing/context.ts +40 -0
- package/runtime/tracing/db.ts +73 -0
- package/runtime/tracing/frames.ts +75 -0
- package/runtime/tracing/instrument.ts +186 -0
- package/runtime/tracing/span.ts +138 -0
- package/runtime/tracing/store.ts +499 -0
- package/runtime/tracing/types.ts +47 -0
- package/runtime/vite-plugin/config-plugin.ts +68 -0
- package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
- package/runtime/vite-plugin/dist/index.mjs +52333 -0
- package/runtime/vite-plugin/globals-plugin.ts +94 -0
- package/runtime/vite-plugin/index.ts +43 -0
- package/runtime/vite-plugin/modules-plugin.ts +88 -0
- package/runtime/vite-plugin/react-router-plugin.ts +95 -0
- package/runtime/worker-registry.ts +52 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useState, useEffect } from "preact/hooks";
|
|
2
|
+
import type { QueryResult } from "../rpc/types";
|
|
3
|
+
import type { useHistory } from "./hooks";
|
|
4
|
+
import { HistoryPanel } from "./history-panels";
|
|
5
|
+
|
|
6
|
+
export function SqlConsoleTab({ execQuery, initialSql, history }: {
|
|
7
|
+
execQuery: (sql: string) => Promise<QueryResult>;
|
|
8
|
+
initialSql?: string;
|
|
9
|
+
history: ReturnType<typeof useHistory>;
|
|
10
|
+
}) {
|
|
11
|
+
const [sql, setSql] = useState(initialSql ?? "");
|
|
12
|
+
const [result, setResult] = useState<QueryResult | null>(null);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
16
|
+
|
|
17
|
+
// Update SQL when initialSql changes (e.g. from "open in console")
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (initialSql) setSql(initialSql);
|
|
20
|
+
}, [initialSql]);
|
|
21
|
+
|
|
22
|
+
const run = async () => {
|
|
23
|
+
if (!sql.trim() || loading) return;
|
|
24
|
+
history.add(sql);
|
|
25
|
+
setLoading(true);
|
|
26
|
+
setError(null);
|
|
27
|
+
setResult(null);
|
|
28
|
+
try {
|
|
29
|
+
const res = await execQuery(sql);
|
|
30
|
+
if (res.error) setError(res.error);
|
|
31
|
+
else setResult(res);
|
|
32
|
+
} catch (e: any) {
|
|
33
|
+
setError(e.message ?? String(e));
|
|
34
|
+
} finally {
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<div class="bg-panel rounded-lg border border-border p-5 mb-6">
|
|
42
|
+
<textarea
|
|
43
|
+
value={sql}
|
|
44
|
+
onInput={e => setSql((e.target as HTMLTextAreaElement).value)}
|
|
45
|
+
onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) run(); }}
|
|
46
|
+
placeholder="SELECT * FROM ..."
|
|
47
|
+
class="w-full bg-panel-secondary border border-border rounded-lg px-4 py-3 font-mono text-sm outline-none min-h-[100px] resize-y focus:border-border focus:ring-1 focus:ring-border transition-all mb-4"
|
|
48
|
+
/>
|
|
49
|
+
<div class="flex items-center gap-3">
|
|
50
|
+
<button
|
|
51
|
+
onClick={run}
|
|
52
|
+
disabled={loading || !sql.trim()}
|
|
53
|
+
class="rounded-md px-4 py-2 text-sm font-medium bg-ink text-surface hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
54
|
+
>
|
|
55
|
+
{loading ? "Running..." : "Run Query"}
|
|
56
|
+
</button>
|
|
57
|
+
<button
|
|
58
|
+
onClick={() => setShowHistory(v => !v)}
|
|
59
|
+
class={`rounded-md px-3 py-2 text-sm font-medium transition-all ${
|
|
60
|
+
showHistory
|
|
61
|
+
? "bg-ink text-surface"
|
|
62
|
+
: "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
|
|
63
|
+
}`}
|
|
64
|
+
>
|
|
65
|
+
History{history.entries.length > 0 ? ` (${history.entries.length})` : ""}
|
|
66
|
+
</button>
|
|
67
|
+
<span class="text-xs text-text-muted">Ctrl+Enter to run</span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{showHistory && (
|
|
72
|
+
<HistoryPanel
|
|
73
|
+
entries={history.entries}
|
|
74
|
+
onSelect={(entry) => { setSql(entry.sql); setShowHistory(false); }}
|
|
75
|
+
onClear={history.clear}
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{error ? (
|
|
80
|
+
<div class="bg-red-50 text-red-600 p-4 rounded-lg text-sm font-medium">{error}</div>
|
|
81
|
+
) : result ? (
|
|
82
|
+
<div>
|
|
83
|
+
{result.message ? (
|
|
84
|
+
<div class="bg-emerald-50 text-emerald-700 p-4 rounded-lg text-sm font-medium">{result.message}</div>
|
|
85
|
+
) : result.columns.length > 0 ? (
|
|
86
|
+
<div>
|
|
87
|
+
<div class="text-sm text-text-muted mb-3 font-medium">{result.count} row(s)</div>
|
|
88
|
+
<ResultTable columns={result.columns} rows={result.rows} />
|
|
89
|
+
</div>
|
|
90
|
+
) : null}
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── ResultTable (read-only results) ─────────────────────────────────
|
|
98
|
+
|
|
99
|
+
function ResultTable({ columns, rows }: { columns: string[]; rows: Record<string, unknown>[] }) {
|
|
100
|
+
return (
|
|
101
|
+
<div class="bg-panel rounded-lg border border-border overflow-x-auto">
|
|
102
|
+
<table class="w-full text-sm">
|
|
103
|
+
<thead>
|
|
104
|
+
<tr class="border-b border-border-subtle">
|
|
105
|
+
{columns.map(col => (
|
|
106
|
+
<th key={col} class="text-left px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono">{col}</th>
|
|
107
|
+
))}
|
|
108
|
+
</tr>
|
|
109
|
+
</thead>
|
|
110
|
+
<tbody>
|
|
111
|
+
{rows.map((row, i) => (
|
|
112
|
+
<tr key={i} class="group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors">
|
|
113
|
+
{columns.map(col => (
|
|
114
|
+
<td key={col} class="px-4 py-2.5 font-mono text-xs">
|
|
115
|
+
{row[col] === null ? <span class="text-text-dim italic">NULL</span> : String(row[col])}
|
|
116
|
+
</td>
|
|
117
|
+
))}
|
|
118
|
+
</tr>
|
|
119
|
+
))}
|
|
120
|
+
</tbody>
|
|
121
|
+
</table>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback } from "preact/hooks";
|
|
2
|
+
import type { D1Table, QueryResult } from "../rpc/types";
|
|
3
|
+
import { replaceRoute } from "../lib";
|
|
4
|
+
import type { SortDir, ForeignKeyInfo, BrowserHistoryEntry } from "./types";
|
|
5
|
+
import { PAGE_SIZE } from "./types";
|
|
6
|
+
import type { useHistory, useBrowserHistory } from "./hooks";
|
|
7
|
+
import { loadTableState, saveTableState, clearTableState } from "./hooks";
|
|
8
|
+
import { parseCreateTable, sqlLiteral, quoteId, buildWhereClause, exportCSV, exportJSON } from "./utils";
|
|
9
|
+
import { EditableCell } from "./editable-cell";
|
|
10
|
+
import { InsertRowForm } from "./insert-row-form";
|
|
11
|
+
import { FilterRow } from "./filter-row";
|
|
12
|
+
import { FilterHelpModal } from "./filter-row";
|
|
13
|
+
import { BrowserHistoryPanel } from "./history-panels";
|
|
14
|
+
import { RowDetailModal, CellInspectorModal } from "./modals";
|
|
15
|
+
|
|
16
|
+
export function TableDataView({ table, execQuery, onOpenInConsole, history, browserHistory, onRestoreHistory, onNavigateFK, historyScope, basePath, routeQuery }: {
|
|
17
|
+
table: D1Table;
|
|
18
|
+
execQuery: (sql: string) => Promise<QueryResult>;
|
|
19
|
+
onOpenInConsole: (sql: string) => void;
|
|
20
|
+
history: ReturnType<typeof useHistory>;
|
|
21
|
+
browserHistory: ReturnType<typeof useBrowserHistory>;
|
|
22
|
+
onRestoreHistory: (entry: BrowserHistoryEntry) => void;
|
|
23
|
+
onNavigateFK: (targetTable: string, targetColumn: string, value: unknown) => void;
|
|
24
|
+
historyScope?: string;
|
|
25
|
+
basePath?: string;
|
|
26
|
+
routeQuery?: URLSearchParams;
|
|
27
|
+
}) {
|
|
28
|
+
const schema = parseCreateTable(table.sql);
|
|
29
|
+
const pkCols = schema.primaryKeys.length > 0 ? schema.primaryKeys : ["rowid"];
|
|
30
|
+
const needsRowid = schema.primaryKeys.length === 0;
|
|
31
|
+
|
|
32
|
+
// FK map for quick lookup
|
|
33
|
+
const fkMap = new Map<string, ForeignKeyInfo>();
|
|
34
|
+
for (const col of schema.columns) {
|
|
35
|
+
if (col.foreignKey) fkMap.set(col.name, col.foreignKey);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Numeric columns for right-alignment
|
|
39
|
+
const numericTypes = /\b(INT|INTEGER|REAL|FLOAT|DOUBLE|DECIMAL|NUMERIC|BIGINT|SMALLINT|TINYINT|MEDIUMINT)\b/i;
|
|
40
|
+
const numericCols = new Set(schema.columns.filter(c => numericTypes.test(c.type)).map(c => c.name));
|
|
41
|
+
|
|
42
|
+
// Initialize state from URL query params, falling back to saved table state
|
|
43
|
+
const initState = () => {
|
|
44
|
+
const f: Record<string, string> = {};
|
|
45
|
+
let s: string | null = null;
|
|
46
|
+
let d: SortDir = "ASC";
|
|
47
|
+
let hasUrlParams = false;
|
|
48
|
+
|
|
49
|
+
if (routeQuery) {
|
|
50
|
+
for (const [key, val] of routeQuery.entries()) {
|
|
51
|
+
if (key.startsWith("f.")) { f[key.slice(2)] = val; hasUrlParams = true; }
|
|
52
|
+
}
|
|
53
|
+
const urlSort = routeQuery.get("s");
|
|
54
|
+
if (urlSort) { s = urlSort; hasUrlParams = true; }
|
|
55
|
+
const urlDir = routeQuery.get("d");
|
|
56
|
+
if (urlDir === "DESC") d = "DESC";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!hasUrlParams) {
|
|
60
|
+
const saved = loadTableState(table.name, historyScope);
|
|
61
|
+
if (saved) {
|
|
62
|
+
return { filters: saved.filters, sortCol: saved.sortCol, sortDir: saved.sortDir };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { filters: f, sortCol: s, sortDir: d };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const initial = initState();
|
|
69
|
+
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
|
70
|
+
const [columns, setColumns] = useState<string[]>([]);
|
|
71
|
+
const [totalCount, setTotalCount] = useState<number>(table.rows);
|
|
72
|
+
const [offset, setOffset] = useState(() => {
|
|
73
|
+
const o = routeQuery?.get("o");
|
|
74
|
+
return o ? parseInt(o, 10) || 0 : 0;
|
|
75
|
+
});
|
|
76
|
+
const [sortCol, setSortCol] = useState<string | null>(initial.sortCol);
|
|
77
|
+
const [sortDir, setSortDir] = useState<SortDir>(initial.sortDir);
|
|
78
|
+
const [loading, setLoading] = useState(false);
|
|
79
|
+
const [error, setError] = useState<string | null>(null);
|
|
80
|
+
const [showInsert, setShowInsert] = useState(false);
|
|
81
|
+
const [filters, setFilters] = useState<Record<string, string>>(initial.filters);
|
|
82
|
+
const [showFilters, setShowFilters] = useState(() => Object.keys(initial.filters).length > 0);
|
|
83
|
+
const [showFilterHelp, setShowFilterHelp] = useState(false);
|
|
84
|
+
const [showBrowserHistory, setShowBrowserHistory] = useState(false);
|
|
85
|
+
|
|
86
|
+
// Bulk select
|
|
87
|
+
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
|
88
|
+
const rowKey = (row: Record<string, unknown>) => pkCols.map(pk => String(row[pk] ?? "")).join("\0");
|
|
89
|
+
|
|
90
|
+
// Row detail & cell inspector modals
|
|
91
|
+
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
|
|
92
|
+
const [inspectCell, setInspectCell] = useState<{ column: string; value: unknown } | null>(null);
|
|
93
|
+
|
|
94
|
+
// Export dropdown
|
|
95
|
+
const [showExport, setShowExport] = useState(false);
|
|
96
|
+
|
|
97
|
+
// Sync state → URL + persist table view state
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
// Save to localStorage
|
|
100
|
+
const hasState = Object.values(filters).some(v => v.trim()) || sortCol !== null;
|
|
101
|
+
if (hasState) {
|
|
102
|
+
saveTableState(table.name, { filters, sortCol, sortDir }, historyScope);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!basePath) return;
|
|
106
|
+
const params = new URLSearchParams();
|
|
107
|
+
for (const [col, val] of Object.entries(filters)) {
|
|
108
|
+
if (val.trim()) params.set("f." + col, val);
|
|
109
|
+
}
|
|
110
|
+
if (sortCol) {
|
|
111
|
+
params.set("s", sortCol);
|
|
112
|
+
params.set("d", sortDir);
|
|
113
|
+
}
|
|
114
|
+
if (offset > 0) params.set("o", String(offset));
|
|
115
|
+
const qs = params.toString();
|
|
116
|
+
replaceRoute(basePath + "/data/" + encodeURIComponent(table.name) + (qs ? "?" + qs : ""));
|
|
117
|
+
}, [filters, sortCol, sortDir, offset, basePath, table.name, historyScope]);
|
|
118
|
+
|
|
119
|
+
const filtersKey = JSON.stringify(filters);
|
|
120
|
+
const loadGenRef = useRef(0);
|
|
121
|
+
|
|
122
|
+
const loadData = useCallback(async (newOffset: number) => {
|
|
123
|
+
const gen = ++loadGenRef.current;
|
|
124
|
+
setLoading(true);
|
|
125
|
+
setError(null);
|
|
126
|
+
try {
|
|
127
|
+
const selectCols = needsRowid ? `rowid, *` : `*`;
|
|
128
|
+
const where = buildWhereClause(filters);
|
|
129
|
+
const orderBy = sortCol ? ` ORDER BY ${quoteId(sortCol)} ${sortDir}` : "";
|
|
130
|
+
const dataSql = `SELECT ${selectCols} FROM ${quoteId(table.name)}${where}${orderBy} LIMIT ${PAGE_SIZE} OFFSET ${newOffset}`;
|
|
131
|
+
const countSql = `SELECT COUNT(*) as cnt FROM ${quoteId(table.name)}${where}`;
|
|
132
|
+
|
|
133
|
+
const [dataRes, countRes] = await Promise.all([
|
|
134
|
+
execQuery(dataSql),
|
|
135
|
+
execQuery(countSql),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
if (gen !== loadGenRef.current) return;
|
|
139
|
+
|
|
140
|
+
if (dataRes.error) {
|
|
141
|
+
setError(dataRes.error);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
setRows(dataRes.rows);
|
|
146
|
+
setColumns(dataRes.columns);
|
|
147
|
+
setOffset(newOffset);
|
|
148
|
+
if (countRes.rows?.[0]) {
|
|
149
|
+
setTotalCount(Number(countRes.rows[0].cnt));
|
|
150
|
+
}
|
|
151
|
+
// Save to browser history when there are filters or sort
|
|
152
|
+
if (where || orderBy) {
|
|
153
|
+
browserHistory.add({ table: table.name, filters, sortCol, sortDir });
|
|
154
|
+
}
|
|
155
|
+
} catch (e: any) {
|
|
156
|
+
if (gen !== loadGenRef.current) return;
|
|
157
|
+
setError(e.message ?? String(e));
|
|
158
|
+
} finally {
|
|
159
|
+
if (gen === loadGenRef.current) setLoading(false);
|
|
160
|
+
}
|
|
161
|
+
}, [table.name, sortCol, sortDir, filtersKey, needsRowid, execQuery]);
|
|
162
|
+
|
|
163
|
+
// Reload when table, sort, or filters change
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
setOffset(0);
|
|
166
|
+
setShowInsert(false);
|
|
167
|
+
setSelectedRows(new Set());
|
|
168
|
+
loadData(0);
|
|
169
|
+
}, [table.name, sortCol, sortDir, filtersKey]);
|
|
170
|
+
|
|
171
|
+
const handleSort = (col: string) => {
|
|
172
|
+
if (sortCol === col) {
|
|
173
|
+
setSortDir(d => d === "ASC" ? "DESC" : "ASC");
|
|
174
|
+
} else {
|
|
175
|
+
setSortCol(col);
|
|
176
|
+
setSortDir("ASC");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const handleUpdate = async (row: Record<string, unknown>, col: string, value: unknown) => {
|
|
181
|
+
const where = pkCols.map(pk => `${quoteId(pk)} = ${sqlLiteral(row[pk])}`).join(" AND ");
|
|
182
|
+
const sql = `UPDATE ${quoteId(table.name)} SET ${quoteId(col)} = ${sqlLiteral(value)} WHERE ${where}`;
|
|
183
|
+
try {
|
|
184
|
+
const res = await execQuery(sql);
|
|
185
|
+
if (res.error) {
|
|
186
|
+
setError(res.error);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await loadData(offset);
|
|
190
|
+
} catch (e: any) {
|
|
191
|
+
setError(e.message ?? String(e));
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const handleDelete = async (row: Record<string, unknown>) => {
|
|
196
|
+
if (!confirm("Delete this row?")) return;
|
|
197
|
+
const where = pkCols.map(pk => `${quoteId(pk)} = ${sqlLiteral(row[pk])}`).join(" AND ");
|
|
198
|
+
const sql = `DELETE FROM ${quoteId(table.name)} WHERE ${where}`;
|
|
199
|
+
try {
|
|
200
|
+
const res = await execQuery(sql);
|
|
201
|
+
if (res.error) {
|
|
202
|
+
setError(res.error);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
await loadData(offset);
|
|
206
|
+
} catch (e: any) {
|
|
207
|
+
setError(e.message ?? String(e));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const handleInsert = async (values: Record<string, unknown>) => {
|
|
212
|
+
const cols = Object.keys(values);
|
|
213
|
+
const vals = cols.map(c => sqlLiteral(values[c]));
|
|
214
|
+
const sql = `INSERT INTO ${quoteId(table.name)} (${cols.map(quoteId).join(", ")}) VALUES (${vals.join(", ")})`;
|
|
215
|
+
try {
|
|
216
|
+
const res = await execQuery(sql);
|
|
217
|
+
if (res.error) {
|
|
218
|
+
setError(res.error);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
setShowInsert(false);
|
|
222
|
+
await loadData(offset);
|
|
223
|
+
} catch (e: any) {
|
|
224
|
+
setError(e.message ?? String(e));
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Bulk select handlers
|
|
229
|
+
const toggleRow = (row: Record<string, unknown>) => {
|
|
230
|
+
const key = rowKey(row);
|
|
231
|
+
setSelectedRows(prev => {
|
|
232
|
+
const next = new Set(prev);
|
|
233
|
+
if (next.has(key)) next.delete(key);
|
|
234
|
+
else next.add(key);
|
|
235
|
+
return next;
|
|
236
|
+
});
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const toggleAll = () => {
|
|
240
|
+
if (selectedRows.size === rows.length && rows.length > 0) {
|
|
241
|
+
setSelectedRows(new Set());
|
|
242
|
+
} else {
|
|
243
|
+
setSelectedRows(new Set(rows.map(rowKey)));
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const handleBulkDelete = async () => {
|
|
248
|
+
if (selectedRows.size === 0) return;
|
|
249
|
+
if (!confirm(`Delete ${selectedRows.size} row(s)?`)) return;
|
|
250
|
+
const toDelete = rows.filter(r => selectedRows.has(rowKey(r)));
|
|
251
|
+
const conditions = toDelete.map(row =>
|
|
252
|
+
`(${pkCols.map(pk => `${quoteId(pk)} = ${sqlLiteral(row[pk])}`).join(" AND ")})`
|
|
253
|
+
);
|
|
254
|
+
const sql = `DELETE FROM ${quoteId(table.name)} WHERE ${conditions.join(" OR ")}`;
|
|
255
|
+
try {
|
|
256
|
+
const res = await execQuery(sql);
|
|
257
|
+
if (res.error) {
|
|
258
|
+
setError(res.error);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
setSelectedRows(new Set());
|
|
262
|
+
await loadData(offset);
|
|
263
|
+
} catch (e: any) {
|
|
264
|
+
setError(e.message ?? String(e));
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// Columns to display (hide rowid if it was added just for PK tracking)
|
|
269
|
+
const displayCols = columns.filter(c => !(needsRowid && c === "rowid"));
|
|
270
|
+
const activeFilterCount = Object.values(filters).filter(v => v.trim()).length;
|
|
271
|
+
const hasActiveState = activeFilterCount > 0 || sortCol !== null;
|
|
272
|
+
|
|
273
|
+
const handleReset = () => {
|
|
274
|
+
setFilters({});
|
|
275
|
+
setSortCol(null);
|
|
276
|
+
setSortDir("ASC");
|
|
277
|
+
setShowFilters(false);
|
|
278
|
+
clearTableState(table.name, historyScope);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// Current query SQL (for display / open-in-console)
|
|
282
|
+
const where = buildWhereClause(filters);
|
|
283
|
+
const orderBy = sortCol ? ` ORDER BY ${quoteId(sortCol)} ${sortDir}` : "";
|
|
284
|
+
const currentSql = `SELECT * FROM ${quoteId(table.name)}${where}${orderBy}`;
|
|
285
|
+
|
|
286
|
+
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
287
|
+
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
|
288
|
+
const rangeStart = totalCount === 0 ? 0 : offset + 1;
|
|
289
|
+
const rangeEnd = Math.min(offset + PAGE_SIZE, totalCount);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<div>
|
|
293
|
+
{/* Toolbar */}
|
|
294
|
+
<div class="flex items-center justify-between mb-4">
|
|
295
|
+
<div class="flex items-center gap-3">
|
|
296
|
+
<h3 class="text-lg font-bold font-mono">{table.name}</h3>
|
|
297
|
+
<span class="text-xs text-text-muted tabular-nums">{totalCount} row(s)</span>
|
|
298
|
+
{selectedRows.size > 0 && (
|
|
299
|
+
<button
|
|
300
|
+
onClick={handleBulkDelete}
|
|
301
|
+
class="rounded-md px-3 py-1.5 text-sm font-medium bg-red-500 text-white hover:bg-red-600 transition-all"
|
|
302
|
+
>
|
|
303
|
+
Delete selected ({selectedRows.size})
|
|
304
|
+
</button>
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
<div class="flex items-center gap-2">
|
|
308
|
+
<button
|
|
309
|
+
onClick={() => setShowFilters(v => !v)}
|
|
310
|
+
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
|
|
311
|
+
showFilters || activeFilterCount > 0
|
|
312
|
+
? "bg-ink text-surface"
|
|
313
|
+
: "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
|
|
314
|
+
}`}
|
|
315
|
+
>
|
|
316
|
+
Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
|
|
317
|
+
</button>
|
|
318
|
+
{hasActiveState && (
|
|
319
|
+
<button
|
|
320
|
+
onClick={handleReset}
|
|
321
|
+
class="rounded-md px-3 py-1.5 text-sm font-medium text-red-400 bg-panel border border-border hover:text-red-600 hover:bg-red-50 transition-all"
|
|
322
|
+
title="Clear all filters and sorting"
|
|
323
|
+
>
|
|
324
|
+
Reset
|
|
325
|
+
</button>
|
|
326
|
+
)}
|
|
327
|
+
<button
|
|
328
|
+
onClick={() => setShowFilterHelp(true)}
|
|
329
|
+
class="rounded-md w-7 h-7 text-sm font-bold bg-panel border border-border text-text-muted hover:text-text-data hover:bg-panel-hover transition-all"
|
|
330
|
+
title="Filter syntax help"
|
|
331
|
+
>
|
|
332
|
+
?
|
|
333
|
+
</button>
|
|
334
|
+
<button
|
|
335
|
+
onClick={() => setShowBrowserHistory(v => !v)}
|
|
336
|
+
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
|
|
337
|
+
showBrowserHistory
|
|
338
|
+
? "bg-ink text-surface"
|
|
339
|
+
: "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
|
|
340
|
+
}`}
|
|
341
|
+
>
|
|
342
|
+
History{browserHistory.entries.length > 0 ? ` (${browserHistory.entries.length})` : ""}
|
|
343
|
+
</button>
|
|
344
|
+
<div class="relative">
|
|
345
|
+
<button
|
|
346
|
+
onClick={() => setShowExport(v => !v)}
|
|
347
|
+
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
|
|
348
|
+
showExport
|
|
349
|
+
? "bg-ink text-surface"
|
|
350
|
+
: "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
|
|
351
|
+
}`}
|
|
352
|
+
>
|
|
353
|
+
Export
|
|
354
|
+
</button>
|
|
355
|
+
{showExport && (
|
|
356
|
+
<div class="absolute right-0 top-full mt-1 bg-panel rounded-lg border border-border shadow-lg z-10 py-1 min-w-[120px]">
|
|
357
|
+
<button
|
|
358
|
+
onClick={() => { exportCSV(displayCols, rows, table.name); setShowExport(false); }}
|
|
359
|
+
class="w-full text-left px-3 py-2 text-sm text-text-data hover:bg-panel-hover transition-colors"
|
|
360
|
+
>
|
|
361
|
+
CSV
|
|
362
|
+
</button>
|
|
363
|
+
<button
|
|
364
|
+
onClick={() => { exportJSON(rows, table.name); setShowExport(false); }}
|
|
365
|
+
class="w-full text-left px-3 py-2 text-sm text-text-data hover:bg-panel-hover transition-colors"
|
|
366
|
+
>
|
|
367
|
+
JSON
|
|
368
|
+
</button>
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
<button
|
|
373
|
+
onClick={() => setShowInsert(!showInsert)}
|
|
374
|
+
class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
|
|
375
|
+
showInsert
|
|
376
|
+
? "bg-panel-active text-text-data"
|
|
377
|
+
: "bg-ink text-surface hover:opacity-80"
|
|
378
|
+
}`}
|
|
379
|
+
>
|
|
380
|
+
{showInsert ? "Cancel" : "+ Add Row"}
|
|
381
|
+
</button>
|
|
382
|
+
<button
|
|
383
|
+
onClick={() => loadData(offset)}
|
|
384
|
+
disabled={loading}
|
|
385
|
+
class="rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover disabled:opacity-40 transition-all"
|
|
386
|
+
>
|
|
387
|
+
Refresh
|
|
388
|
+
</button>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{/* Current SQL */}
|
|
393
|
+
<div
|
|
394
|
+
onClick={() => onOpenInConsole(currentSql)}
|
|
395
|
+
class="mb-4 px-3 py-2 bg-panel-secondary border border-border rounded-lg flex items-center gap-2 cursor-pointer hover:bg-panel-hover hover:border-border transition-colors group"
|
|
396
|
+
title="Open in SQL Console"
|
|
397
|
+
>
|
|
398
|
+
<code class="flex-1 text-xs font-mono text-text-secondary truncate">{currentSql}</code>
|
|
399
|
+
<span class="text-xs text-text-dim group-hover:text-text-secondary transition-colors flex-shrink-0">→ SQL Console</span>
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
{/* Browser history */}
|
|
403
|
+
{showBrowserHistory && (
|
|
404
|
+
<BrowserHistoryPanel
|
|
405
|
+
entries={browserHistory.entries}
|
|
406
|
+
currentTable={table.name}
|
|
407
|
+
onSelect={(entry) => { onRestoreHistory(entry); setShowBrowserHistory(false); }}
|
|
408
|
+
onClear={browserHistory.clear}
|
|
409
|
+
/>
|
|
410
|
+
)}
|
|
411
|
+
|
|
412
|
+
{/* Error banner */}
|
|
413
|
+
{error && (
|
|
414
|
+
<div class="bg-red-50 text-red-600 p-3 rounded-lg text-sm font-medium mb-4 flex items-center justify-between">
|
|
415
|
+
<span>{error}</span>
|
|
416
|
+
<button onClick={() => setError(null)} class="text-red-400 hover:text-red-600 ml-3 text-xs">dismiss</button>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
|
|
420
|
+
{/* Data table */}
|
|
421
|
+
<div class="bg-panel rounded-lg border border-border overflow-x-auto">
|
|
422
|
+
<table class="w-full text-sm">
|
|
423
|
+
<thead>
|
|
424
|
+
<tr class="border-b border-border-subtle">
|
|
425
|
+
<th class="w-10 px-3 py-2.5">
|
|
426
|
+
<input
|
|
427
|
+
type="checkbox"
|
|
428
|
+
checked={rows.length > 0 && selectedRows.size === rows.length}
|
|
429
|
+
onChange={toggleAll}
|
|
430
|
+
class="rounded border-border accent-ink"
|
|
431
|
+
/>
|
|
432
|
+
</th>
|
|
433
|
+
{displayCols.map(col => (
|
|
434
|
+
<th
|
|
435
|
+
key={col}
|
|
436
|
+
onClick={() => handleSort(col)}
|
|
437
|
+
class={`${numericCols.has(col) ? "text-right" : "text-left"} px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono cursor-pointer hover:text-text-data select-none`}
|
|
438
|
+
>
|
|
439
|
+
{col}
|
|
440
|
+
{fkMap.has(col) && <span class="ml-1 text-link text-[10px]" title={`FK → ${fkMap.get(col)!.targetTable}`}>FK</span>}
|
|
441
|
+
{sortCol === col && (
|
|
442
|
+
<span class="ml-1">{sortDir === "ASC" ? "\u2191" : "\u2193"}</span>
|
|
443
|
+
)}
|
|
444
|
+
</th>
|
|
445
|
+
))}
|
|
446
|
+
<th class="w-24 px-4 py-2.5"></th>
|
|
447
|
+
</tr>
|
|
448
|
+
{showFilters && (
|
|
449
|
+
<FilterRow
|
|
450
|
+
columns={displayCols}
|
|
451
|
+
filters={filters}
|
|
452
|
+
onFilterChange={(col, val) => setFilters(f => {
|
|
453
|
+
const next = { ...f };
|
|
454
|
+
if (val) next[col] = val;
|
|
455
|
+
else delete next[col];
|
|
456
|
+
return next;
|
|
457
|
+
})}
|
|
458
|
+
onClearAll={() => setFilters({})}
|
|
459
|
+
hasCheckboxCol
|
|
460
|
+
/>
|
|
461
|
+
)}
|
|
462
|
+
</thead>
|
|
463
|
+
<tbody>
|
|
464
|
+
{showInsert && (
|
|
465
|
+
<InsertRowForm
|
|
466
|
+
schema={schema}
|
|
467
|
+
displayCols={displayCols}
|
|
468
|
+
onSave={handleInsert}
|
|
469
|
+
onCancel={() => setShowInsert(false)}
|
|
470
|
+
hasCheckboxCol
|
|
471
|
+
/>
|
|
472
|
+
)}
|
|
473
|
+
{loading && rows.length === 0 ? (
|
|
474
|
+
<tr>
|
|
475
|
+
<td colSpan={displayCols.length + 2} class="px-4 py-8 text-center text-text-muted text-sm">Loading...</td>
|
|
476
|
+
</tr>
|
|
477
|
+
) : rows.length === 0 ? (
|
|
478
|
+
<tr>
|
|
479
|
+
<td colSpan={displayCols.length + 2} class="px-4 py-8 text-center text-text-muted text-sm">No rows</td>
|
|
480
|
+
</tr>
|
|
481
|
+
) : (
|
|
482
|
+
rows.map((row, i) => (
|
|
483
|
+
<tr key={i} class={`group border-b border-border-subtle last:border-0 hover:bg-panel-hover/50 transition-colors ${selectedRows.has(rowKey(row)) ? "bg-blue-500/10" : i % 2 === 1 ? "bg-panel-hover/20" : ""}`}>
|
|
484
|
+
<td class="px-3 py-2">
|
|
485
|
+
<input
|
|
486
|
+
type="checkbox"
|
|
487
|
+
checked={selectedRows.has(rowKey(row))}
|
|
488
|
+
onChange={() => toggleRow(row)}
|
|
489
|
+
class="rounded border-border accent-ink"
|
|
490
|
+
/>
|
|
491
|
+
</td>
|
|
492
|
+
{displayCols.map(col => (
|
|
493
|
+
<td key={col} class="px-4 py-0">
|
|
494
|
+
<EditableCell
|
|
495
|
+
value={row[col]}
|
|
496
|
+
onSave={(v) => handleUpdate(row, col, v)}
|
|
497
|
+
foreignKey={fkMap.get(col) ?? null}
|
|
498
|
+
onNavigateFK={(fk) => onNavigateFK(fk.targetTable, fk.targetColumn, row[col])}
|
|
499
|
+
onInspect={() => setInspectCell({ column: col, value: row[col] })}
|
|
500
|
+
alignRight={numericCols.has(col)}
|
|
501
|
+
/>
|
|
502
|
+
</td>
|
|
503
|
+
))}
|
|
504
|
+
<td class="px-4 py-2 text-right whitespace-nowrap">
|
|
505
|
+
<button
|
|
506
|
+
onClick={() => setDetailRow(row)}
|
|
507
|
+
class="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-data text-xs font-medium rounded-md px-2 py-1 hover:bg-panel-hover transition-all mr-1"
|
|
508
|
+
>
|
|
509
|
+
Detail
|
|
510
|
+
</button>
|
|
511
|
+
<button
|
|
512
|
+
onClick={() => handleDelete(row)}
|
|
513
|
+
class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs font-medium rounded-md px-2 py-1 hover:bg-red-50 transition-all"
|
|
514
|
+
>
|
|
515
|
+
Delete
|
|
516
|
+
</button>
|
|
517
|
+
</td>
|
|
518
|
+
</tr>
|
|
519
|
+
))
|
|
520
|
+
)}
|
|
521
|
+
</tbody>
|
|
522
|
+
</table>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
{/* Pagination */}
|
|
526
|
+
<div class="flex items-center justify-between mt-4">
|
|
527
|
+
<span class="text-xs text-text-muted tabular-nums">{rangeStart}–{rangeEnd} of {totalCount}</span>
|
|
528
|
+
<div class="flex items-center gap-2">
|
|
529
|
+
<button
|
|
530
|
+
onClick={() => loadData(offset - PAGE_SIZE)}
|
|
531
|
+
disabled={offset === 0 || loading}
|
|
532
|
+
class="rounded-md px-3 py-1.5 text-xs font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
533
|
+
>
|
|
534
|
+
Prev
|
|
535
|
+
</button>
|
|
536
|
+
<span class="text-xs text-text-muted tabular-nums">{currentPage} / {totalPages}</span>
|
|
537
|
+
<button
|
|
538
|
+
onClick={() => loadData(offset + PAGE_SIZE)}
|
|
539
|
+
disabled={offset + PAGE_SIZE >= totalCount || loading}
|
|
540
|
+
class="rounded-md px-3 py-1.5 text-xs font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
|
|
541
|
+
>
|
|
542
|
+
Next
|
|
543
|
+
</button>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
{showFilterHelp && <FilterHelpModal onClose={() => setShowFilterHelp(false)} />}
|
|
548
|
+
{detailRow && (
|
|
549
|
+
<RowDetailModal
|
|
550
|
+
columns={displayCols}
|
|
551
|
+
row={detailRow}
|
|
552
|
+
fkMap={fkMap}
|
|
553
|
+
onClose={() => setDetailRow(null)}
|
|
554
|
+
onNavigateFK={(t, c, v) => { setDetailRow(null); onNavigateFK(t, c, v); }}
|
|
555
|
+
/>
|
|
556
|
+
)}
|
|
557
|
+
{inspectCell && (
|
|
558
|
+
<CellInspectorModal
|
|
559
|
+
column={inspectCell.column}
|
|
560
|
+
value={inspectCell.value}
|
|
561
|
+
onClose={() => setInspectCell(null)}
|
|
562
|
+
/>
|
|
563
|
+
)}
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
}
|