sqlite-hub 0.17.0 → 0.17.2
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/frontend/js/api.js +6 -0
- package/frontend/js/app.js +119 -26
- package/frontend/js/components/modal.js +26 -6
- package/frontend/js/components/queryResults.js +18 -1
- package/frontend/js/components/rowEditorPanel.js +42 -34
- package/frontend/js/store.js +14 -2
- package/frontend/js/utils/jsonPreview.js +31 -0
- package/frontend/js/utils/rowEditorValues.js +41 -0
- package/frontend/js/utils/tableScrollState.js +39 -0
- package/frontend/js/views/charts.js +8 -0
- package/frontend/js/views/data.js +4 -1
- package/frontend/js/views/editor.js +2 -0
- package/frontend/styles/components.css +6 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/package.json +1 -1
- package/server/middleware/localRequestSecurity.js +84 -0
- package/server/routes/connections.js +18 -1
- package/server/server.js +12 -1
- package/server/services/nativeFileDialogService.js +168 -0
- package/server/services/sqlite/dataBrowserService.js +0 -4
- package/server/services/sqlite/exportService.js +5 -1
- package/server/services/sqlite/sqlExecutor.js +51 -2
- package/server/utils/sqliteTypes.js +17 -8
- package/tests/connections-file-dialog-route.test.js +46 -0
- package/tests/copy-column-modal.test.js +14 -0
- package/tests/export-blob.test.js +46 -0
- package/tests/json-preview.test.js +49 -0
- package/tests/local-request-security.test.js +85 -0
- package/tests/native-file-dialog.test.js +78 -0
- package/tests/query-results-truncation.test.js +20 -0
- package/tests/row-editor-null-values.test.js +131 -0
- package/tests/sql-identifier-safety.test.js +9 -3
- package/tests/sql-result-limit.test.js +66 -0
- package/tests/table-scroll-state.test.js +70 -0
package/frontend/js/api.js
CHANGED
|
@@ -86,6 +86,12 @@ export function createConnection(payload) {
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
export function chooseCreateDatabasePath() {
|
|
90
|
+
return request("/api/connections/choose-create-path", {
|
|
91
|
+
method: "POST",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
89
95
|
export function importSql(payload) {
|
|
90
96
|
return request("/api/connections/import-sql", {
|
|
91
97
|
method: "POST",
|
package/frontend/js/app.js
CHANGED
|
@@ -24,6 +24,7 @@ import { renderTopNav } from './components/topNav.js';
|
|
|
24
24
|
import { createRouter } from './router.js';
|
|
25
25
|
import {
|
|
26
26
|
createActiveConnectionBackup,
|
|
27
|
+
chooseCreateDatabasePath,
|
|
27
28
|
createDocument,
|
|
28
29
|
createDocumentFromMarkdownExport,
|
|
29
30
|
clearCurrentQuery,
|
|
@@ -178,10 +179,19 @@ import {
|
|
|
178
179
|
stringifyRowEditorJson,
|
|
179
180
|
} from './utils/rowEditorJson.js';
|
|
180
181
|
import { getTimestampPreviewForField } from './utils/timestampPreview.js';
|
|
182
|
+
import {
|
|
183
|
+
buildRowEditorSubmittedValues,
|
|
184
|
+
getRowEditorValueState,
|
|
185
|
+
getRowEditorValueStateLabel,
|
|
186
|
+
} from './utils/rowEditorValues.js';
|
|
181
187
|
import {
|
|
182
188
|
formatTextCellCharacterCount,
|
|
183
189
|
getTextCellCharacterCount,
|
|
184
190
|
} from './utils/textCellStats.js';
|
|
191
|
+
import {
|
|
192
|
+
captureTableHorizontalScrollState,
|
|
193
|
+
restoreTableHorizontalScrollState,
|
|
194
|
+
} from './utils/tableScrollState.js';
|
|
185
195
|
|
|
186
196
|
const appRoot = document.querySelector('#app');
|
|
187
197
|
|
|
@@ -1257,6 +1267,10 @@ function renderApp(state) {
|
|
|
1257
1267
|
|
|
1258
1268
|
const focusedInput = captureFocusedInputState();
|
|
1259
1269
|
const queryHistoryScrollState = captureQueryHistoryScrollState();
|
|
1270
|
+
const tableHorizontalScrollState = captureTableHorizontalScrollState({
|
|
1271
|
+
routeName: lastRenderedRouteName,
|
|
1272
|
+
scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
|
|
1273
|
+
});
|
|
1260
1274
|
const isEnteringNewTableDesignerRoute =
|
|
1261
1275
|
state.route.name === 'tableDesigner' && state.route.params?.isNew && previousRoutePath !== state.route.path;
|
|
1262
1276
|
|
|
@@ -1329,6 +1343,14 @@ function renderApp(state) {
|
|
|
1329
1343
|
restoreQueryHistoryScrollState(queryHistoryScrollState);
|
|
1330
1344
|
}
|
|
1331
1345
|
|
|
1346
|
+
if (mainChanged && !mainPatched) {
|
|
1347
|
+
restoreTableHorizontalScrollState({
|
|
1348
|
+
snapshot: tableHorizontalScrollState,
|
|
1349
|
+
routeName: state.route.name,
|
|
1350
|
+
scrollNodes: shellRefs.view.querySelectorAll('[data-table-horizontal-scroll]'),
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1332
1354
|
if (pendingQueryEditorFocus && (state.route.name === 'editor' || state.route.name === 'editorResults')) {
|
|
1333
1355
|
if (focusQueryEditorInput()) {
|
|
1334
1356
|
pendingQueryEditorFocus = false;
|
|
@@ -1572,6 +1594,53 @@ function syncRowEditorFilePathPreview(inputNode) {
|
|
|
1572
1594
|
previewNode.hidden = false;
|
|
1573
1595
|
}
|
|
1574
1596
|
|
|
1597
|
+
function getRowEditorValueStateClassName(state) {
|
|
1598
|
+
if (state === 'null') {
|
|
1599
|
+
return 'border px-2 py-1 text-[9px] border-primary-container/35 bg-primary-container/15 text-primary-container';
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (state === 'empty') {
|
|
1603
|
+
return 'border px-2 py-1 text-[9px] border-outline-variant/35 bg-surface-container-high text-on-surface-variant';
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return 'border px-2 py-1 text-[9px] border-outline-variant/20 bg-surface-container text-on-surface-variant';
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function syncRowEditorValueState(controlNode) {
|
|
1610
|
+
const fieldNode = controlNode.closest('[data-row-editor-field]');
|
|
1611
|
+
const valueInput = fieldNode?.querySelector('[data-row-editor-value-source]');
|
|
1612
|
+
const stateBadge = fieldNode?.querySelector('[data-row-editor-value-state]');
|
|
1613
|
+
|
|
1614
|
+
if (!fieldNode || !valueInput || !stateBadge) {
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
const valueState = getRowEditorValueState(valueInput.value);
|
|
1619
|
+
|
|
1620
|
+
valueInput.dataset.rowEditorDirty = 'true';
|
|
1621
|
+
stateBadge.dataset.valueState = valueState;
|
|
1622
|
+
stateBadge.className = getRowEditorValueStateClassName(valueState);
|
|
1623
|
+
stateBadge.textContent = getRowEditorValueStateLabel(valueState);
|
|
1624
|
+
|
|
1625
|
+
syncRowEditorTimestampPreview(valueInput);
|
|
1626
|
+
syncRowEditorFilePathPreview(valueInput);
|
|
1627
|
+
syncRowEditorCharacterCount(valueInput);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function getRowEditorFieldMetadata(form) {
|
|
1631
|
+
return Object.fromEntries(
|
|
1632
|
+
Array.from(form.elements)
|
|
1633
|
+
.filter(element => element.name?.startsWith('field:'))
|
|
1634
|
+
.map(element => [
|
|
1635
|
+
element.name.slice('field:'.length),
|
|
1636
|
+
{
|
|
1637
|
+
initialState: element.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState,
|
|
1638
|
+
dirty: element.dataset.rowEditorDirty === 'true',
|
|
1639
|
+
},
|
|
1640
|
+
]),
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1575
1644
|
function closeCopyColumnMenus(exceptMenu = null) {
|
|
1576
1645
|
document.querySelectorAll('[data-copy-column-menu][open]').forEach(menu => {
|
|
1577
1646
|
if (menu !== exceptMenu && menu instanceof HTMLDetailsElement) {
|
|
@@ -1732,21 +1801,24 @@ function applyChangedRowEditorFormValues(rowObject) {
|
|
|
1732
1801
|
|
|
1733
1802
|
const nextRowObject = { ...rowObject };
|
|
1734
1803
|
const formData = new FormData(form);
|
|
1804
|
+
const submittedValues = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
|
|
1735
1805
|
|
|
1736
|
-
for (const [
|
|
1737
|
-
|
|
1738
|
-
continue;
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1806
|
+
for (const [fieldName, value] of Object.entries(submittedValues)) {
|
|
1807
|
+
const key = `field:${fieldName}`;
|
|
1741
1808
|
const control = Array.from(form.elements).find(element => element.name === key);
|
|
1742
1809
|
const initialValue = control?.dataset?.rowEditorInitialValue;
|
|
1743
|
-
const
|
|
1810
|
+
const initialState = control?.closest('[data-row-editor-field]')?.dataset?.rowEditorInitialState;
|
|
1811
|
+
const currentState = getRowEditorValueState(value);
|
|
1812
|
+
const currentValue = value === null ? null : String(value);
|
|
1744
1813
|
|
|
1745
|
-
if (
|
|
1814
|
+
if (
|
|
1815
|
+
initialState === currentState &&
|
|
1816
|
+
(currentState === 'null' || (initialValue !== undefined && currentValue === initialValue))
|
|
1817
|
+
) {
|
|
1746
1818
|
continue;
|
|
1747
1819
|
}
|
|
1748
1820
|
|
|
1749
|
-
nextRowObject[
|
|
1821
|
+
nextRowObject[fieldName] = currentValue;
|
|
1750
1822
|
}
|
|
1751
1823
|
|
|
1752
1824
|
return nextRowObject;
|
|
@@ -2109,6 +2181,33 @@ async function handleAction(actionNode) {
|
|
|
2109
2181
|
case 'edit-connection':
|
|
2110
2182
|
openEditConnectionModal(actionNode.dataset.connectionId);
|
|
2111
2183
|
return;
|
|
2184
|
+
case 'choose-create-database-path': {
|
|
2185
|
+
const labelNode = actionNode.querySelector('[data-create-database-path-button-label]');
|
|
2186
|
+
|
|
2187
|
+
actionNode.setAttribute('disabled', '');
|
|
2188
|
+
if (labelNode) {
|
|
2189
|
+
labelNode.textContent = 'Choosing...';
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const selectedPath = await chooseCreateDatabasePath();
|
|
2193
|
+
const pathInput = document.querySelector(
|
|
2194
|
+
'[data-form="create-connection"] [data-create-database-path]',
|
|
2195
|
+
);
|
|
2196
|
+
|
|
2197
|
+
if (selectedPath && pathInput instanceof HTMLInputElement) {
|
|
2198
|
+
pathInput.value = selectedPath;
|
|
2199
|
+
pathInput.focus({ preventScroll: true });
|
|
2200
|
+
pathInput.setSelectionRange(pathInput.value.length, pathInput.value.length);
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
if (actionNode.isConnected) {
|
|
2204
|
+
actionNode.removeAttribute('disabled');
|
|
2205
|
+
if (labelNode) {
|
|
2206
|
+
labelNode.textContent = 'Browse...';
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2112
2211
|
case 'close-modal':
|
|
2113
2212
|
closeModal();
|
|
2114
2213
|
return;
|
|
@@ -2689,6 +2788,7 @@ document.addEventListener('keydown', event => {
|
|
|
2689
2788
|
|
|
2690
2789
|
document.addEventListener('input', event => {
|
|
2691
2790
|
const target = event.target instanceof Element ? event.target : null;
|
|
2791
|
+
const valueInput = target?.closest('[data-row-editor-value-source]');
|
|
2692
2792
|
const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
|
|
2693
2793
|
const textCellInput = target?.closest('[data-row-editor-text-source]');
|
|
2694
2794
|
|
|
@@ -2701,6 +2801,10 @@ document.addEventListener('input', event => {
|
|
|
2701
2801
|
syncRowEditorCharacterCount(textCellInput);
|
|
2702
2802
|
}
|
|
2703
2803
|
|
|
2804
|
+
if (valueInput) {
|
|
2805
|
+
syncRowEditorValueState(valueInput);
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2704
2808
|
const bindNode = event.target.closest('[data-bind]');
|
|
2705
2809
|
|
|
2706
2810
|
if (!bindNode) {
|
|
@@ -2845,6 +2949,7 @@ document.addEventListener(
|
|
|
2845
2949
|
|
|
2846
2950
|
document.addEventListener('change', event => {
|
|
2847
2951
|
const target = event.target instanceof Element ? event.target : null;
|
|
2952
|
+
const valueControl = target?.closest('[data-row-editor-value-source]');
|
|
2848
2953
|
const timestampInput = target?.closest('[data-row-editor-timestamp-source]');
|
|
2849
2954
|
const textCellInput = target?.closest('[data-row-editor-text-source]');
|
|
2850
2955
|
|
|
@@ -2857,6 +2962,10 @@ document.addEventListener('change', event => {
|
|
|
2857
2962
|
syncRowEditorCharacterCount(textCellInput);
|
|
2858
2963
|
}
|
|
2859
2964
|
|
|
2965
|
+
if (valueControl) {
|
|
2966
|
+
syncRowEditorValueState(valueControl);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2860
2969
|
const bindNode = event.target.closest('[data-bind]');
|
|
2861
2970
|
|
|
2862
2971
|
if (!bindNode) {
|
|
@@ -3126,15 +3235,7 @@ document.addEventListener('submit', async event => {
|
|
|
3126
3235
|
await submitCopyColumnModal(formData);
|
|
3127
3236
|
return;
|
|
3128
3237
|
case 'save-data-row': {
|
|
3129
|
-
const values =
|
|
3130
|
-
|
|
3131
|
-
for (const [key, value] of formData.entries()) {
|
|
3132
|
-
if (!key.startsWith('field:')) {
|
|
3133
|
-
continue;
|
|
3134
|
-
}
|
|
3135
|
-
|
|
3136
|
-
values[key.slice('field:'.length)] = String(value ?? '');
|
|
3137
|
-
}
|
|
3238
|
+
const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
|
|
3138
3239
|
|
|
3139
3240
|
let rowIdentity = null;
|
|
3140
3241
|
const rawRowIdentity = String(formData.get('rowIdentity') ?? '').trim();
|
|
@@ -3151,15 +3252,7 @@ document.addEventListener('submit', async event => {
|
|
|
3151
3252
|
return;
|
|
3152
3253
|
}
|
|
3153
3254
|
case 'save-editor-row': {
|
|
3154
|
-
const values =
|
|
3155
|
-
|
|
3156
|
-
for (const [key, value] of formData.entries()) {
|
|
3157
|
-
if (!key.startsWith('field:')) {
|
|
3158
|
-
continue;
|
|
3159
|
-
}
|
|
3160
|
-
|
|
3161
|
-
values[key.slice('field:'.length)] = String(value ?? '');
|
|
3162
|
-
}
|
|
3255
|
+
const values = buildRowEditorSubmittedValues(formData, getRowEditorFieldMetadata(form));
|
|
3163
3256
|
|
|
3164
3257
|
await openEditorRowUpdatePreview(String(formData.get('rowIndex') ?? ''), values);
|
|
3165
3258
|
return;
|
|
@@ -264,14 +264,34 @@ function renderEditConnectionForm(modal) {
|
|
|
264
264
|
`;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
function renderCreateDatabaseForm(modal) {
|
|
267
|
+
export function renderCreateDatabaseForm(modal) {
|
|
268
268
|
return `
|
|
269
269
|
<form class="space-y-5" data-form="create-connection">
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
<label class="block space-y-2">
|
|
271
|
+
<span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
|
|
272
|
+
New SQLite File Path
|
|
273
|
+
</span>
|
|
274
|
+
<span class="flex items-stretch gap-2">
|
|
275
|
+
<input
|
|
276
|
+
class="control-input min-w-0 flex-1 border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
|
|
277
|
+
data-create-database-path
|
|
278
|
+
name="path"
|
|
279
|
+
placeholder="/absolute/path/to/new-database.sqlite"
|
|
280
|
+
type="text"
|
|
281
|
+
/>
|
|
282
|
+
<button
|
|
283
|
+
class="standard-button flex-none"
|
|
284
|
+
data-action="choose-create-database-path"
|
|
285
|
+
type="button"
|
|
286
|
+
>
|
|
287
|
+
<span class="material-symbols-outlined text-sm">folder_open</span>
|
|
288
|
+
<span data-create-database-path-button-label>Browse...</span>
|
|
289
|
+
</button>
|
|
290
|
+
</span>
|
|
291
|
+
<span class="block text-[11px] leading-5 text-on-surface-variant/60">
|
|
292
|
+
Choose a folder and filename, or enter an absolute path manually.
|
|
293
|
+
</span>
|
|
294
|
+
</label>
|
|
275
295
|
${renderField({
|
|
276
296
|
label: "Label",
|
|
277
297
|
name: "label",
|
|
@@ -95,6 +95,7 @@ export function renderQueryResultsPane(
|
|
|
95
95
|
sortDirection = null,
|
|
96
96
|
resultScope = "editor",
|
|
97
97
|
sortAction = "sort-editor-results-column",
|
|
98
|
+
scrollKey = "",
|
|
98
99
|
} = {}
|
|
99
100
|
) {
|
|
100
101
|
if (!result) {
|
|
@@ -124,7 +125,23 @@ export function renderQueryResultsPane(
|
|
|
124
125
|
|
|
125
126
|
return `
|
|
126
127
|
<div class="relative flex h-full min-h-0 flex-col overflow-hidden bg-surface-container">
|
|
127
|
-
|
|
128
|
+
${
|
|
129
|
+
result.truncated
|
|
130
|
+
? `
|
|
131
|
+
<div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-2 text-xs text-on-surface">
|
|
132
|
+
Showing the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query or export it to process the complete result set.
|
|
133
|
+
</div>
|
|
134
|
+
`
|
|
135
|
+
: ""
|
|
136
|
+
}
|
|
137
|
+
<div
|
|
138
|
+
class="custom-scrollbar min-h-0 flex-1 overflow-auto bg-surface-container-lowest"
|
|
139
|
+
${
|
|
140
|
+
scrollKey
|
|
141
|
+
? `data-table-horizontal-scroll data-table-scroll-key="${escapeHtml(scrollKey)}"`
|
|
142
|
+
: ""
|
|
143
|
+
}
|
|
144
|
+
>
|
|
128
145
|
${
|
|
129
146
|
result.columns?.length
|
|
130
147
|
? renderDataGrid({
|
|
@@ -12,37 +12,14 @@ import {
|
|
|
12
12
|
formatTextCellCharacterCount,
|
|
13
13
|
getTextCellCharacterCount,
|
|
14
14
|
} from "../utils/textCellStats.js";
|
|
15
|
+
import { formatJsonPreview } from "../utils/jsonPreview.js";
|
|
16
|
+
import {
|
|
17
|
+
getRowEditorValueState,
|
|
18
|
+
getRowEditorValueStateLabel,
|
|
19
|
+
} from "../utils/rowEditorValues.js";
|
|
15
20
|
|
|
16
21
|
const URL_PATTERN = /^https?:\/\/[^\s<>"']+$/i;
|
|
17
22
|
|
|
18
|
-
function getJsonPreview(value) {
|
|
19
|
-
if (value === null || value === undefined) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (typeof value === "object") {
|
|
24
|
-
return JSON.stringify(value, null, 2);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const text = String(value).trim();
|
|
28
|
-
|
|
29
|
-
if (!text || !["{", "["].includes(text[0])) {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
const parsed = JSON.parse(text);
|
|
35
|
-
|
|
36
|
-
if (!parsed || typeof parsed !== "object") {
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return JSON.stringify(parsed, null, 2);
|
|
41
|
-
} catch (error) {
|
|
42
|
-
return null;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
23
|
function getUrlValue(value) {
|
|
47
24
|
const text = String(value ?? "").trim();
|
|
48
25
|
|
|
@@ -119,7 +96,7 @@ function renderOpenUrlButton(url) {
|
|
|
119
96
|
}
|
|
120
97
|
|
|
121
98
|
return `
|
|
122
|
-
<div class="mt-2">
|
|
99
|
+
<div class="mt-2" data-row-editor-url-action>
|
|
123
100
|
<button
|
|
124
101
|
class="standard-button"
|
|
125
102
|
data-action="open-row-editor-url"
|
|
@@ -141,7 +118,7 @@ function renderAllowedValuesSelect(field, allowedValues) {
|
|
|
141
118
|
currentValue !== "" && !hasCurrentAllowedValue;
|
|
142
119
|
const options = [
|
|
143
120
|
shouldRenderEmptyOption
|
|
144
|
-
? `<option value="" ${currentValue === "" ? "selected" : ""}>
|
|
121
|
+
? `<option value="" ${currentValue === "" ? "selected" : ""}>Empty string</option>`
|
|
145
122
|
: "",
|
|
146
123
|
shouldRenderCurrentOption
|
|
147
124
|
? `<option value="${escapeHtml(currentValue)}" selected>${escapeHtml(currentValue)}</option>`
|
|
@@ -160,6 +137,7 @@ function renderAllowedValuesSelect(field, allowedValues) {
|
|
|
160
137
|
data-row-editor-initial-value="${escapeHtml(currentValue)}"
|
|
161
138
|
data-row-editor-text-source
|
|
162
139
|
data-row-editor-timestamp-source
|
|
140
|
+
data-row-editor-value-source
|
|
163
141
|
name="field:${escapeHtml(field.name)}"
|
|
164
142
|
>
|
|
165
143
|
${options}
|
|
@@ -284,7 +262,7 @@ function renderJsonViewer(prettyJson, title = "JSON Viewer") {
|
|
|
284
262
|
<div class="text-[10px] font-mono uppercase tracking-[0.18em] text-primary-container/75">
|
|
285
263
|
${escapeHtml(title)}
|
|
286
264
|
</div>
|
|
287
|
-
<pre class="custom-scrollbar mt-3 max-h-[18rem] overflow-auto
|
|
265
|
+
<pre class="row-editor-json-preview custom-scrollbar mt-3 max-h-[18rem] overflow-auto border border-outline-variant/10 bg-surface-container-lowest px-4 py-4 font-mono text-xs leading-6 text-on-surface">${escapeHtml(
|
|
288
266
|
prettyJson
|
|
289
267
|
)}</pre>
|
|
290
268
|
</div>
|
|
@@ -301,7 +279,8 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
|
|
|
301
279
|
const url = getUrlValue(value);
|
|
302
280
|
const badges = withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview);
|
|
303
281
|
const displayLabel = typeof label === "object" ? label.label : label;
|
|
304
|
-
const jsonPreview =
|
|
282
|
+
const jsonPreview = formatJsonPreview(value);
|
|
283
|
+
const valueState = getRowEditorValueState(rawValue);
|
|
305
284
|
|
|
306
285
|
return `
|
|
307
286
|
<div
|
|
@@ -315,6 +294,7 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
|
|
|
315
294
|
<div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
|
|
316
295
|
<span>${escapeHtml(displayLabel)}</span>
|
|
317
296
|
${renderFieldBadgesWithCharacterCount(badges, rawValue)}
|
|
297
|
+
${renderValueStateBadge(valueState)}
|
|
318
298
|
</div>
|
|
319
299
|
${
|
|
320
300
|
jsonPreview
|
|
@@ -328,6 +308,28 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
|
|
|
328
308
|
`;
|
|
329
309
|
}
|
|
330
310
|
|
|
311
|
+
function getValueStateBadgeClassName(state) {
|
|
312
|
+
if (state === "null") {
|
|
313
|
+
return "border-primary-container/35 bg-primary-container/15 text-primary-container";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (state === "empty") {
|
|
317
|
+
return "border-outline-variant/35 bg-surface-container-high text-on-surface-variant";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return "border-outline-variant/20 bg-surface-container text-on-surface-variant";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function renderValueStateBadge(state) {
|
|
324
|
+
return `
|
|
325
|
+
<span
|
|
326
|
+
class="border px-2 py-1 text-[9px] ${getValueStateBadgeClassName(state)}"
|
|
327
|
+
data-row-editor-value-state
|
|
328
|
+
data-value-state="${escapeHtml(state)}"
|
|
329
|
+
>${escapeHtml(getRowEditorValueStateLabel(state))}</span>
|
|
330
|
+
`;
|
|
331
|
+
}
|
|
332
|
+
|
|
331
333
|
function renderEditableField(field, tableMeta = {}) {
|
|
332
334
|
const columnName = getFieldColumnName(field);
|
|
333
335
|
const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
|
|
@@ -336,25 +338,28 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
336
338
|
const filePathPreview = getFieldFilePathPreview(field, tableMeta);
|
|
337
339
|
const baseBadges = withCheckBadge(Array.isArray(field.badges) ? field.badges : [], allowedValues);
|
|
338
340
|
const badges = withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview);
|
|
339
|
-
const jsonPreview =
|
|
341
|
+
const jsonPreview = formatJsonPreview(field.value);
|
|
340
342
|
const inputType = field.inputType === "number" ? "number" : "text";
|
|
341
343
|
const numberStep = field.numberStep === "1" ? "1" : "any";
|
|
344
|
+
const valueState = getRowEditorValueState(getFieldRawValue(field));
|
|
342
345
|
|
|
343
346
|
return `
|
|
344
347
|
<div
|
|
345
348
|
class="block space-y-2"
|
|
346
349
|
data-row-editor-column-name="${escapeHtml(columnName)}"
|
|
347
350
|
data-row-editor-field
|
|
351
|
+
data-row-editor-initial-state="${escapeHtml(valueState)}"
|
|
348
352
|
data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
|
|
349
353
|
${url ? "data-row-editor-url-field" : ""}
|
|
350
354
|
>
|
|
351
355
|
<span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
|
|
352
356
|
<span>${escapeHtml(field.label ?? field.name)}</span>
|
|
353
357
|
${renderFieldBadgesWithCharacterCount(badges, inputType === "number" ? null : field.value ?? "")}
|
|
358
|
+
${renderValueStateBadge(valueState)}
|
|
354
359
|
</span>
|
|
355
360
|
${
|
|
356
361
|
jsonPreview
|
|
357
|
-
? renderJsonViewer(jsonPreview, "JSON Preview")
|
|
362
|
+
? `<div data-row-editor-json-preview-container>${renderJsonViewer(jsonPreview, "JSON Preview")}</div>`
|
|
358
363
|
: ""
|
|
359
364
|
}
|
|
360
365
|
${
|
|
@@ -366,6 +371,7 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
366
371
|
class="w-full border border-outline-variant/20 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
|
|
367
372
|
data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
|
|
368
373
|
data-row-editor-timestamp-source
|
|
374
|
+
data-row-editor-value-source
|
|
369
375
|
name="field:${escapeHtml(field.name)}"
|
|
370
376
|
step="${escapeHtml(numberStep)}"
|
|
371
377
|
type="number"
|
|
@@ -379,6 +385,7 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
379
385
|
data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
|
|
380
386
|
data-row-editor-text-source
|
|
381
387
|
data-row-editor-timestamp-source
|
|
388
|
+
data-row-editor-value-source
|
|
382
389
|
name="field:${escapeHtml(field.name)}"
|
|
383
390
|
data-row-editor-url-input
|
|
384
391
|
spellcheck="false"
|
|
@@ -395,6 +402,7 @@ function renderEditableField(field, tableMeta = {}) {
|
|
|
395
402
|
data-row-editor-initial-value="${escapeHtml(field.value ?? "")}"
|
|
396
403
|
data-row-editor-text-source
|
|
397
404
|
data-row-editor-timestamp-source
|
|
405
|
+
data-row-editor-value-source
|
|
398
406
|
name="field:${escapeHtml(field.name)}"
|
|
399
407
|
spellcheck="false"
|
|
400
408
|
>${escapeHtml(field.value ?? "")}</textarea>
|
package/frontend/js/store.js
CHANGED
|
@@ -3490,6 +3490,16 @@ export async function submitCreateConnection(payload) {
|
|
|
3490
3490
|
}
|
|
3491
3491
|
}
|
|
3492
3492
|
|
|
3493
|
+
export async function chooseCreateDatabasePath() {
|
|
3494
|
+
try {
|
|
3495
|
+
const response = await api.chooseCreateDatabasePath();
|
|
3496
|
+
return response.data?.path ?? null;
|
|
3497
|
+
} catch (error) {
|
|
3498
|
+
pushToast(normalizeError(error).message || 'Native file dialog could not be opened.', 'alert');
|
|
3499
|
+
return null;
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
|
|
3493
3503
|
export async function submitImportSql(payload) {
|
|
3494
3504
|
startModalSubmission();
|
|
3495
3505
|
|
|
@@ -5303,11 +5313,13 @@ export function getQueryMessages(snapshot = state) {
|
|
|
5303
5313
|
|
|
5304
5314
|
return [
|
|
5305
5315
|
...snapshot.editor.result.statements.map(statement => ({
|
|
5306
|
-
tone: statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
|
|
5316
|
+
tone: statement.kind === 'resultSet' && statement.truncated ? 'warning' : statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
|
|
5307
5317
|
label: `${statement.keyword} #${statement.index + 1}`,
|
|
5308
5318
|
value:
|
|
5309
5319
|
statement.kind === 'resultSet'
|
|
5310
|
-
?
|
|
5320
|
+
? statement.truncated
|
|
5321
|
+
? `Showing the first ${statement.rowCount} row(s); the result was limited to ${statement.rowLimit}.`
|
|
5322
|
+
: `${statement.rowCount} row(s) returned.`
|
|
5311
5323
|
: `${statement.changes} row(s) affected.`,
|
|
5312
5324
|
})),
|
|
5313
5325
|
...queryMessages,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function formatJsonPreview(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let parsed = value;
|
|
7
|
+
|
|
8
|
+
if (typeof value !== "object") {
|
|
9
|
+
const text = String(value).trim();
|
|
10
|
+
|
|
11
|
+
if (!text || !["{", "["].includes(text[0])) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(text);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!parsed || typeof parsed !== "object") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return JSON.stringify(parsed, null, 2);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function getRowEditorValueState(value) {
|
|
2
|
+
if (value === null || value === undefined) {
|
|
3
|
+
return "null";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return String(value) === "" ? "empty" : "value";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getRowEditorValueStateLabel(state) {
|
|
10
|
+
if (state === "null") {
|
|
11
|
+
return "NULL";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (state === "empty") {
|
|
15
|
+
return "EMPTY STRING";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return "VALUE";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildRowEditorSubmittedValues(formData, fieldMetadata = {}) {
|
|
22
|
+
const fieldNames = new Set();
|
|
23
|
+
|
|
24
|
+
for (const [key] of formData.entries()) {
|
|
25
|
+
if (key.startsWith("field:")) {
|
|
26
|
+
fieldNames.add(key.slice("field:".length));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Object.fromEntries(
|
|
31
|
+
[...fieldNames].map((fieldName) => {
|
|
32
|
+
const valueKey = `field:${fieldName}`;
|
|
33
|
+
const value = String(formData.get(valueKey) ?? "");
|
|
34
|
+
const metadata = fieldMetadata[fieldName] ?? {};
|
|
35
|
+
const keepInitialNull =
|
|
36
|
+
metadata.initialState === "null" && metadata.dirty !== true && value === "";
|
|
37
|
+
|
|
38
|
+
return [fieldName, keepInitialNull ? null : value];
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function captureTableHorizontalScrollState({ routeName = "", scrollNodes = [] } = {}) {
|
|
2
|
+
return {
|
|
3
|
+
routeName: String(routeName ?? ""),
|
|
4
|
+
positions: Array.from(scrollNodes)
|
|
5
|
+
.map((scrollNode) => ({
|
|
6
|
+
key: String(scrollNode?.dataset?.tableScrollKey ?? ""),
|
|
7
|
+
scrollLeft: Number(scrollNode?.scrollLeft) || 0,
|
|
8
|
+
}))
|
|
9
|
+
.filter((position) => position.key),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function restoreTableHorizontalScrollState({
|
|
14
|
+
snapshot,
|
|
15
|
+
routeName = "",
|
|
16
|
+
scrollNodes = [],
|
|
17
|
+
} = {}) {
|
|
18
|
+
if (!snapshot || snapshot.routeName !== String(routeName ?? "")) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const candidates = Array.from(scrollNodes);
|
|
23
|
+
let restored = false;
|
|
24
|
+
|
|
25
|
+
for (const position of snapshot.positions ?? []) {
|
|
26
|
+
const scrollNode = candidates.find(
|
|
27
|
+
(candidate) => String(candidate?.dataset?.tableScrollKey ?? "") === position.key
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (!scrollNode) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
scrollNode.scrollLeft = position.scrollLeft;
|
|
35
|
+
restored = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return restored;
|
|
39
|
+
}
|
|
@@ -220,6 +220,14 @@ function renderQueryResultState(state, result) {
|
|
|
220
220
|
`;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
if (result.truncated) {
|
|
224
|
+
return `
|
|
225
|
+
<div class="border-b border-primary-container/20 bg-primary-container/10 px-4 py-3 text-sm text-on-surface">
|
|
226
|
+
Charts use the first ${escapeHtml(String(result.rowLimit ?? result.rows?.length ?? 0))} rows. Refine the query for a complete visualization.
|
|
227
|
+
</div>
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
223
231
|
return '';
|
|
224
232
|
}
|
|
225
233
|
|