admindb 1.0.3 → 1.2.0
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 +151 -47
- package/dist/app.d.ts +12 -0
- package/dist/app.js +7 -0
- package/dist/app.js.map +1 -1
- package/dist/args.d.ts +28 -0
- package/dist/args.js +187 -0
- package/dist/args.js.map +1 -0
- package/dist/cli.js +85 -15
- package/dist/cli.js.map +1 -1
- package/dist/db/database.d.ts +58 -37
- package/dist/db/database.js +239 -42
- package/dist/db/database.js.map +1 -1
- package/dist/db/manager.d.ts +8 -2
- package/dist/db/manager.js +33 -1
- package/dist/db/manager.js.map +1 -1
- package/dist/public/css/app.css +1 -1
- package/dist/public/css/input.css +53 -0
- package/dist/public/js/browse.js +847 -86
- package/dist/public/js/databases.js +99 -1
- package/dist/routes/api.js +261 -97
- package/dist/routes/api.js.map +1 -1
- package/dist/routes/databases.d.ts +10 -0
- package/dist/routes/databases.js +97 -0
- package/dist/routes/databases.js.map +1 -1
- package/dist/routes/pages.js +50 -30
- package/dist/routes/pages.js.map +1 -1
- package/dist/sql/generator.d.ts +0 -22
- package/dist/sql/generator.js +0 -48
- package/dist/sql/generator.js.map +1 -1
- package/dist/test/database.test.js +241 -63
- package/dist/test/database.test.js.map +1 -1
- package/dist/test/generator.test.js +0 -32
- package/dist/test/generator.test.js.map +1 -1
- package/dist/util.d.ts +18 -1
- package/dist/util.js +37 -2
- package/dist/util.js.map +1 -1
- package/dist/views/layouts/main.hbs +1 -1
- package/dist/views/pages/databases.hbs +41 -0
- package/dist/views/pages/table.hbs +81 -28
- package/dist/views/partials/sidebar.hbs +0 -12
- package/package.json +1 -1
- package/dist/public/js/triggers.js +0 -155
- package/dist/public/js/views.js +0 -176
- package/dist/views/pages/triggers.hbs +0 -162
- package/dist/views/pages/views.hbs +0 -134
package/dist/public/js/browse.js
CHANGED
|
@@ -1,31 +1,71 @@
|
|
|
1
|
-
/*
|
|
2
|
-
*
|
|
1
|
+
/* Table browse page.
|
|
2
|
+
*
|
|
3
|
+
* Features:
|
|
4
|
+
* - Inline (spreadsheet-style) cell editing — double-click a cell, edit in
|
|
5
|
+
* place with a type-aware control (FK dropdown, boolean toggle, date picker,
|
|
6
|
+
* number input, text input). Changes are staged in the grid and marked as
|
|
7
|
+
* pending; press "Apply" to write them all at once (single transaction), or
|
|
8
|
+
* "Discard" to revert every pending cell back to the server values.
|
|
9
|
+
* - Type-aware row filters — the filter form adapts to each column's type
|
|
10
|
+
* (FK dropdowns, boolean toggles, date/number ranges, plain text).
|
|
11
|
+
* - Bulk row operations — row checkboxes + batch delete (with FK-impact
|
|
12
|
+
* warnings) and batch CSV/JSON export.
|
|
13
|
+
* - Plus the existing single-row delete, page-size, CSV import and
|
|
14
|
+
* related-rows expander.
|
|
15
|
+
*/
|
|
3
16
|
(function () {
|
|
4
17
|
'use strict';
|
|
5
18
|
|
|
6
19
|
const base = (window.APP && window.APP.basePath) || '';
|
|
20
|
+
const readonly = !!(window.APP && window.APP.readonly);
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
const cfgEl = document.getElementById('browse-config');
|
|
23
|
+
const cfg = cfgEl
|
|
24
|
+
? JSON.parse(cfgEl.textContent)
|
|
25
|
+
: { table: '', filters: {}, pkCols: [], hasPk: false, readonly: readonly };
|
|
26
|
+
const t = encodeURIComponent(cfg.table);
|
|
27
|
+
|
|
28
|
+
function escapeHtml(s) {
|
|
29
|
+
return (window.UI && UI.escapeHtml) ? UI.escapeHtml(s) : String(s == null ? '' : s);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let info = null; // TableInfoData from /api/tables/:table/info
|
|
33
|
+
let fkOptions = {}; // { [from]: [{ value, label }] }
|
|
34
|
+
|
|
35
|
+
function colInfo(name) {
|
|
36
|
+
return info ? info.columns.find((c) => c.name === name) : null;
|
|
37
|
+
}
|
|
38
|
+
function isPk(name) {
|
|
39
|
+
return !!info && info.primaryKey.indexOf(name) !== -1;
|
|
40
|
+
}
|
|
41
|
+
function fkFor(name) {
|
|
42
|
+
return info ? info.foreignKeys.find((f) => f.from === name) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---- Single row delete --------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function initRowDelete() {
|
|
48
|
+
document.querySelectorAll('.js-delete-row').forEach((btn) => {
|
|
49
|
+
btn.addEventListener('click', () => {
|
|
50
|
+
const table = btn.dataset.table;
|
|
51
|
+
const pk = btn.dataset.pk;
|
|
52
|
+
if (!table || !pk) return;
|
|
53
|
+
if (!window.confirm('Delete this row? This cannot be undone.')) return;
|
|
54
|
+
Api.del('/api/tables/' + encodeURIComponent(table) + '/row/' + encodeURIComponent(pk))
|
|
55
|
+
.then(() => {
|
|
56
|
+
UI.showToast('Row deleted.', 'success');
|
|
57
|
+
window.location.reload();
|
|
58
|
+
})
|
|
59
|
+
.catch((e) => UI.showError(e.message));
|
|
60
|
+
});
|
|
22
61
|
});
|
|
23
|
-
}
|
|
62
|
+
}
|
|
24
63
|
|
|
25
64
|
// ---- Page size (keeps current filters) ----------------------------------
|
|
26
65
|
|
|
27
|
-
|
|
28
|
-
|
|
66
|
+
function initPageSize() {
|
|
67
|
+
const sizeSel = document.getElementById('page-size');
|
|
68
|
+
if (!sizeSel) return;
|
|
29
69
|
sizeSel.addEventListener('change', () => {
|
|
30
70
|
const table = sizeSel.dataset.table;
|
|
31
71
|
const size = sizeSel.value;
|
|
@@ -40,84 +80,805 @@
|
|
|
40
80
|
});
|
|
41
81
|
}
|
|
42
82
|
|
|
43
|
-
// ----
|
|
83
|
+
// ---- Type-aware row filters ---------------------------------------------
|
|
44
84
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const out = {};
|
|
50
|
-
filterForm.querySelectorAll('.filter-input').forEach((input) => {
|
|
51
|
-
const v = (input.value || '').trim();
|
|
52
|
-
if (v) out[input.name] = v;
|
|
53
|
-
});
|
|
54
|
-
return out;
|
|
55
|
-
}
|
|
56
|
-
function navigate(filters) {
|
|
57
|
-
let table = '';
|
|
58
|
-
const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
|
|
59
|
-
if (m) table = decodeURIComponent(m[1]);
|
|
60
|
-
if (!table) return;
|
|
61
|
-
const qs = new URLSearchParams({ page: '1', size: '50' });
|
|
62
|
-
const keys = Object.keys(filters);
|
|
63
|
-
if (keys.length) qs.set('f', JSON.stringify(filters));
|
|
64
|
-
window.location.href = base + '/tables/' + encodeURIComponent(table) + '?' + qs.toString();
|
|
65
|
-
}
|
|
66
|
-
filterForm.addEventListener('submit', (e) => {
|
|
67
|
-
e.preventDefault();
|
|
68
|
-
navigate(currentFilters());
|
|
69
|
-
});
|
|
70
|
-
if (clearBtn) {
|
|
71
|
-
clearBtn.addEventListener('click', () => navigate({}));
|
|
85
|
+
function filterCondArray(existing) {
|
|
86
|
+
if (existing == null) return [];
|
|
87
|
+
if (Array.isArray(existing)) {
|
|
88
|
+
return existing.filter((x) => x && typeof x === 'object' && typeof x.op === 'string');
|
|
72
89
|
}
|
|
90
|
+
if (typeof existing === 'object') return [existing];
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
function legacyString(existing) {
|
|
94
|
+
return typeof existing === 'string' ? existing : null;
|
|
95
|
+
}
|
|
96
|
+
function condVal(conds, op) {
|
|
97
|
+
const c = conds.find((x) => x.op === op);
|
|
98
|
+
return c ? (c.value ?? null) : null;
|
|
99
|
+
}
|
|
100
|
+
function condBetween(conds) {
|
|
101
|
+
const c = conds.find((x) => x.op === 'between');
|
|
102
|
+
return c ? { min: c.value ?? null, max: c.max ?? null } : { min: null, max: null };
|
|
73
103
|
}
|
|
74
104
|
|
|
75
|
-
|
|
105
|
+
function toDateInputValue(v, dateOnly) {
|
|
106
|
+
const m = String(v).match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}):(\d{2}))?/);
|
|
107
|
+
if (!m) return '';
|
|
108
|
+
if (dateOnly) return m[1];
|
|
109
|
+
return m[1] + 'T' + (m[2] ? m[2] + ':' + m[3] : '00:00');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function addOption(sel, value, label) {
|
|
113
|
+
const opt = document.createElement('option');
|
|
114
|
+
opt.value = value;
|
|
115
|
+
opt.textContent = label;
|
|
116
|
+
sel.appendChild(opt);
|
|
117
|
+
return opt;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildFilterField(col) {
|
|
121
|
+
const wrap = document.createElement('label');
|
|
122
|
+
wrap.className = 'block';
|
|
123
|
+
const label = document.createElement('span');
|
|
124
|
+
label.className = 'field-label !mb-1 font-mono';
|
|
125
|
+
label.textContent = col.name;
|
|
126
|
+
wrap.appendChild(label);
|
|
127
|
+
|
|
128
|
+
const fk = fkFor(col.name);
|
|
129
|
+
const type = (col.type || '').toUpperCase();
|
|
130
|
+
const isBool = type === 'BOOLEAN';
|
|
131
|
+
const isNum = type.startsWith('INTEGER') || type === 'REAL';
|
|
132
|
+
const isDate = type === 'DATE' || type === 'DATETIME';
|
|
133
|
+
const existing = cfg.filters[col.name];
|
|
134
|
+
const conds = filterCondArray(existing);
|
|
135
|
+
const legacy = legacyString(existing);
|
|
136
|
+
|
|
137
|
+
// FK dropdown — exact match on the referenced value.
|
|
138
|
+
if (fk) {
|
|
139
|
+
const sel = document.createElement('select');
|
|
140
|
+
sel.className = 'filter-input field-select !py-1.5 !text-[13px]';
|
|
141
|
+
sel.dataset.column = col.name;
|
|
142
|
+
sel.dataset.kind = 'fk';
|
|
143
|
+
addOption(sel, '', '(any)');
|
|
144
|
+
addOption(sel, '__NULL__', '(not set / NULL)');
|
|
145
|
+
(fkOptions[col.name] || []).forEach((o) => addOption(sel, String(o.value), o.label === '' ? String(o.value) : o.label));
|
|
146
|
+
let selVal = condVal(conds, 'eq');
|
|
147
|
+
if (selVal == null && legacy && legacy.startsWith('=')) selVal = legacy.slice(1);
|
|
148
|
+
if (selVal === '__NULL__') sel.value = '__NULL__';
|
|
149
|
+
else if (selVal != null && [...sel.options].some((o) => o.value === String(selVal))) sel.value = String(selVal);
|
|
150
|
+
wrap.appendChild(sel);
|
|
151
|
+
return wrap;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Boolean toggle.
|
|
155
|
+
if (isBool) {
|
|
156
|
+
const sel = document.createElement('select');
|
|
157
|
+
sel.className = 'filter-input field-select !py-1.5 !text-[13px]';
|
|
158
|
+
sel.dataset.column = col.name;
|
|
159
|
+
sel.dataset.kind = 'bool';
|
|
160
|
+
addOption(sel, '', '(any)');
|
|
161
|
+
addOption(sel, '1', 'true');
|
|
162
|
+
addOption(sel, '0', 'false');
|
|
163
|
+
let selVal = condVal(conds, 'eq');
|
|
164
|
+
if (selVal == null && legacy && legacy.startsWith('=')) selVal = legacy.slice(1);
|
|
165
|
+
if (selVal === '1' || selVal === '0' || selVal === 'true' || selVal === 'false') {
|
|
166
|
+
sel.value = selVal === 'true' || selVal === '1' ? '1' : '0';
|
|
167
|
+
}
|
|
168
|
+
wrap.appendChild(sel);
|
|
169
|
+
return wrap;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Date / datetime range, or numeric range.
|
|
173
|
+
if (isDate || isNum) {
|
|
174
|
+
const row = document.createElement('div');
|
|
175
|
+
row.className = 'flex items-center gap-1.5';
|
|
176
|
+
const lo = document.createElement('input');
|
|
177
|
+
const hi = document.createElement('input');
|
|
178
|
+
if (isDate) {
|
|
179
|
+
lo.type = hi.type = type === 'DATE' ? 'date' : 'datetime-local';
|
|
180
|
+
} else {
|
|
181
|
+
lo.type = hi.type = 'number';
|
|
182
|
+
lo.step = hi.step = type === 'REAL' ? 'any' : '1';
|
|
183
|
+
}
|
|
184
|
+
lo.className = 'filter-input field-input !py-1.5 !text-[13px]';
|
|
185
|
+
hi.className = 'filter-input field-input !py-1.5 !text-[13px]';
|
|
186
|
+
lo.dataset.column = col.name;
|
|
187
|
+
lo.dataset.bound = 'min';
|
|
188
|
+
lo.placeholder = isDate ? 'from' : 'min';
|
|
189
|
+
hi.dataset.column = col.name;
|
|
190
|
+
hi.dataset.bound = 'max';
|
|
191
|
+
hi.placeholder = isDate ? 'to' : 'max';
|
|
192
|
+
const bt = condBetween(conds);
|
|
193
|
+
const minV = condVal(conds, 'gte') ?? condVal(conds, 'gt') ?? bt.min;
|
|
194
|
+
const maxV = condVal(conds, 'lte') ?? condVal(conds, 'lt') ?? bt.max;
|
|
195
|
+
if (minV != null) lo.value = isDate ? toDateInputValue(minV, type === 'DATE') : String(minV);
|
|
196
|
+
if (maxV != null) hi.value = isDate ? toDateInputValue(maxV, type === 'DATE') : String(maxV);
|
|
197
|
+
row.appendChild(lo);
|
|
198
|
+
row.appendChild(hi);
|
|
199
|
+
wrap.appendChild(row);
|
|
200
|
+
return wrap;
|
|
201
|
+
}
|
|
76
202
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
203
|
+
// Text / BLOB / anything else — legacy operator syntax.
|
|
204
|
+
const input = document.createElement('input');
|
|
205
|
+
input.type = 'text';
|
|
206
|
+
input.className = 'filter-input field-input !py-1.5 !text-[13px]';
|
|
207
|
+
input.dataset.column = col.name;
|
|
208
|
+
input.dataset.kind = 'text';
|
|
209
|
+
input.placeholder = type === 'BLOB' ? '0x… or text' : 'filter…';
|
|
210
|
+
input.autocomplete = 'off';
|
|
211
|
+
input.spellcheck = false;
|
|
212
|
+
let textVal = legacy;
|
|
213
|
+
if (textVal == null) {
|
|
214
|
+
const prefix = condVal(conds, 'prefix');
|
|
215
|
+
const eq = condVal(conds, 'eq');
|
|
216
|
+
const like = condVal(conds, 'like');
|
|
217
|
+
if (prefix != null) textVal = String(prefix) + '*';
|
|
218
|
+
else if (eq != null) textVal = '=' + String(eq);
|
|
219
|
+
else if (like != null) textVal = String(like);
|
|
220
|
+
}
|
|
221
|
+
if (textVal != null) input.value = textVal;
|
|
222
|
+
wrap.appendChild(input);
|
|
223
|
+
return wrap;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectFilters() {
|
|
227
|
+
const filters = {};
|
|
228
|
+
const byCol = {};
|
|
229
|
+
document.querySelectorAll('#filter-fields .filter-input').forEach((el) => {
|
|
230
|
+
const col = el.dataset.column;
|
|
231
|
+
if (!col) return;
|
|
232
|
+
(byCol[col] = byCol[col] || []).push(el);
|
|
88
233
|
});
|
|
234
|
+
for (const col of Object.keys(byCol)) {
|
|
235
|
+
const els = byCol[col];
|
|
236
|
+
const first = els[0];
|
|
237
|
+
const kind = first.dataset.kind || '';
|
|
238
|
+
if (kind === 'fk') {
|
|
239
|
+
const v = first.value;
|
|
240
|
+
if (v === '') continue;
|
|
241
|
+
filters[col] = v === '__NULL__' ? { op: 'null' } : { op: 'eq', value: v };
|
|
242
|
+
} else if (kind === 'bool') {
|
|
243
|
+
const v = first.value;
|
|
244
|
+
if (v === '') continue;
|
|
245
|
+
filters[col] = { op: 'eq', value: v };
|
|
246
|
+
} else if (els.length === 2 && first.dataset.bound) {
|
|
247
|
+
const minEl = els.find((e) => e.dataset.bound === 'min');
|
|
248
|
+
const maxEl = els.find((e) => e.dataset.bound === 'max');
|
|
249
|
+
const minV = minEl && minEl.value !== '' ? minEl.value : null;
|
|
250
|
+
const maxV = maxEl && maxEl.value !== '' ? maxEl.value : null;
|
|
251
|
+
if (minV != null && maxV != null) filters[col] = { op: 'between', value: minV, max: maxV };
|
|
252
|
+
else if (minV != null) filters[col] = { op: 'gte', value: minV };
|
|
253
|
+
else if (maxV != null) filters[col] = { op: 'lte', value: maxV };
|
|
254
|
+
} else {
|
|
255
|
+
const v = (first.value || '').trim();
|
|
256
|
+
if (v) filters[col] = v; // legacy string syntax
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return filters;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function navigate(filters) {
|
|
263
|
+
let table = '';
|
|
264
|
+
const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
|
|
265
|
+
if (m) table = decodeURIComponent(m[1]);
|
|
266
|
+
if (!table) return;
|
|
267
|
+
const sizeSel = document.getElementById('page-size');
|
|
268
|
+
const qs = new URLSearchParams({ page: '1', size: sizeSel ? sizeSel.value : '50' });
|
|
269
|
+
const keys = Object.keys(filters);
|
|
270
|
+
if (keys.length) qs.set('f', JSON.stringify(filters));
|
|
271
|
+
window.location.href = base + '/tables/' + encodeURIComponent(table) + '?' + qs.toString();
|
|
89
272
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
};
|
|
101
|
-
|
|
273
|
+
|
|
274
|
+
function initFilters() {
|
|
275
|
+
const filterForm = document.getElementById('filter-form');
|
|
276
|
+
const clearBtn = document.getElementById('clear-filter');
|
|
277
|
+
const fields = document.getElementById('filter-fields');
|
|
278
|
+
if (filterForm && fields && info) {
|
|
279
|
+
info.columns.forEach((col) => fields.appendChild(buildFilterField(col)));
|
|
280
|
+
filterForm.addEventListener('submit', (e) => {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
navigate(collectFilters());
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (clearBtn) clearBtn.addEventListener('click', () => navigate({}));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- Inline (spreadsheet-style) cell editing ----------------------------
|
|
289
|
+
|
|
290
|
+
function renderCellValue(td, value) {
|
|
291
|
+
const isNull = value === null || value === undefined;
|
|
292
|
+
const display = isNull ? '' : String(value);
|
|
293
|
+
td.dataset.null = String(isNull);
|
|
294
|
+
td.dataset.value = display;
|
|
295
|
+
td.classList.remove('js-editing', 'opacity-60');
|
|
296
|
+
td.innerHTML = isNull
|
|
297
|
+
? '<span class="chip chip-null js-cell-value">NULL</span>'
|
|
298
|
+
: '<span class="js-cell-value block max-w-xs truncate font-mono text-[13px]" title="' +
|
|
299
|
+
escapeHtml(display) + '">' + escapeHtml(display.length > 120 ? display.slice(0, 120) + '…' : display) + '</span>';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function currentCellValue(td) {
|
|
303
|
+
return td.dataset.null === 'true' ? null : (td.dataset.value ?? '');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function createEditor(td) {
|
|
307
|
+
const col = td.dataset.col;
|
|
308
|
+
const c = colInfo(col);
|
|
309
|
+
if (!c) return null;
|
|
310
|
+
const fk = fkFor(col);
|
|
311
|
+
const type = (c.type || '').toUpperCase();
|
|
312
|
+
const isBool = type === 'BOOLEAN';
|
|
313
|
+
const isDate = type === 'DATE' || type === 'DATETIME';
|
|
314
|
+
const isNum = type.startsWith('INTEGER') || type === 'REAL';
|
|
315
|
+
const isBlob = type === 'BLOB';
|
|
316
|
+
const value = currentCellValue(td);
|
|
317
|
+
const nullish = value === null || value === '';
|
|
318
|
+
|
|
319
|
+
let el;
|
|
320
|
+
if (isBool) {
|
|
321
|
+
el = document.createElement('input');
|
|
322
|
+
el.type = 'checkbox';
|
|
323
|
+
el.className = 'checkbox checkbox-primary checkbox-sm js-inline-ctrl';
|
|
324
|
+
el.checked = value === 1 || value === true || value === '1' || value === 'true';
|
|
325
|
+
} else if (fk) {
|
|
326
|
+
el = document.createElement('select');
|
|
327
|
+
el.className = 'field-select !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
|
|
328
|
+
addOption(el, '', '(not set / NULL)');
|
|
329
|
+
(fkOptions[col] || []).forEach((o) => {
|
|
330
|
+
const opt = addOption(el, String(o.value), o.label === '' ? String(o.value) : o.label);
|
|
331
|
+
if (!nullish && String(value) === String(o.value)) opt.selected = true;
|
|
332
|
+
});
|
|
333
|
+
} else if (isDate) {
|
|
334
|
+
el = document.createElement('input');
|
|
335
|
+
el.type = type === 'DATE' ? 'date' : 'datetime-local';
|
|
336
|
+
el.className = 'field-input !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
|
|
337
|
+
if (!nullish) el.value = toDateInputValue(String(value), type === 'DATE');
|
|
338
|
+
} else if (isNum) {
|
|
339
|
+
el = document.createElement('input');
|
|
340
|
+
el.type = 'number';
|
|
341
|
+
el.step = type === 'REAL' ? 'any' : '1';
|
|
342
|
+
el.className = 'field-input !w-48 !py-1.5 !text-[13px] js-inline-ctrl';
|
|
343
|
+
if (!nullish) el.value = String(value);
|
|
344
|
+
} else {
|
|
345
|
+
el = document.createElement('input');
|
|
346
|
+
el.type = 'text';
|
|
347
|
+
el.className = 'field-input !w-64 !py-1.5 !text-[13px] js-inline-ctrl';
|
|
348
|
+
el.placeholder = isBlob ? '0x… hex or text' : '';
|
|
349
|
+
if (!nullish) el.value = String(value);
|
|
350
|
+
}
|
|
351
|
+
return el;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function restoreCell(td) {
|
|
355
|
+
renderCellValue(td, td._origNull ? null : td._origValue);
|
|
356
|
+
td.classList.remove('js-editing');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- Staged (Neon-style) cell edits --------------------------------------
|
|
360
|
+
// Edits are buffered locally and marked in the grid; nothing is written to
|
|
361
|
+
// the database until "Apply" sends every pending cell change to the
|
|
362
|
+
// bulk-update endpoint in a single transaction. "Discard" reverts everything
|
|
363
|
+
// back to the values loaded from the server.
|
|
364
|
+
|
|
365
|
+
const pendingChanges = new Map(); // key: `${pk}::${col}` → staged change
|
|
366
|
+
|
|
367
|
+
function stageCell(tr, td, el) {
|
|
368
|
+
const pk = tr.dataset.pk;
|
|
369
|
+
const col = td.dataset.col;
|
|
370
|
+
if (!pk || !col) return;
|
|
371
|
+
const c = colInfo(col);
|
|
372
|
+
if (!c) return;
|
|
373
|
+
|
|
374
|
+
let rawValue;
|
|
375
|
+
let setNull = false;
|
|
376
|
+
if (el.type === 'checkbox') {
|
|
377
|
+
rawValue = el.checked ? '1' : '0';
|
|
378
|
+
} else {
|
|
379
|
+
const v = el.value === undefined ? '' : String(el.value);
|
|
380
|
+
if (v === '' || v === '__NULL__') { rawValue = ''; setNull = true; }
|
|
381
|
+
else rawValue = v;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Remember the server-loaded value the first time this cell is edited.
|
|
385
|
+
if (td.dataset.origValue === undefined) {
|
|
386
|
+
td.dataset.origValue = td.dataset.value;
|
|
387
|
+
td.dataset.origNull = td.dataset.null === 'true' ? 'true' : 'false';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Reflect the staged value in the cell immediately.
|
|
391
|
+
renderCellValue(td, setNull ? null : rawValue);
|
|
392
|
+
|
|
393
|
+
pendingChanges.set(pk + '::' + col, {
|
|
394
|
+
td,
|
|
395
|
+
pk,
|
|
396
|
+
col,
|
|
397
|
+
rawValue,
|
|
398
|
+
setNull,
|
|
399
|
+
isPk: isPk(col),
|
|
400
|
+
isFk: !!fkFor(col),
|
|
401
|
+
origValue: td.dataset.origValue,
|
|
402
|
+
origNull: td.dataset.origNull === 'true',
|
|
102
403
|
});
|
|
404
|
+
|
|
405
|
+
td.classList.add('cell-dirty');
|
|
406
|
+
updateChangesBar();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function updateChangesBar() {
|
|
410
|
+
if (!changesBar || !changesCount) return;
|
|
411
|
+
const n = pendingChanges.size;
|
|
412
|
+
changesCount.textContent = String(n);
|
|
413
|
+
changesBar.classList.toggle('hidden', n === 0);
|
|
103
414
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
415
|
+
|
|
416
|
+
function applyPending() {
|
|
417
|
+
if (!pendingChanges.size || readonly) return;
|
|
418
|
+
const byRow = new Map();
|
|
419
|
+
for (const ch of pendingChanges.values()) {
|
|
420
|
+
let entry = byRow.get(ch.pk);
|
|
421
|
+
if (!entry) {
|
|
422
|
+
entry = { id: ch.pk, values: {}, nulls: [] };
|
|
423
|
+
byRow.set(ch.pk, entry);
|
|
424
|
+
}
|
|
425
|
+
if (ch.setNull) entry.nulls.push(ch.col);
|
|
426
|
+
else entry.values[ch.col] = ch.rawValue;
|
|
427
|
+
}
|
|
428
|
+
const updates = [...byRow.values()];
|
|
429
|
+
if (applyBtn) applyBtn.disabled = true;
|
|
430
|
+
Api.post('/api/tables/' + t + '/rows/bulk-update', { updates })
|
|
431
|
+
.then((data) => {
|
|
432
|
+
UI.showToast(data.message || 'Changes applied.', 'success');
|
|
115
433
|
window.location.reload();
|
|
116
|
-
}
|
|
434
|
+
})
|
|
435
|
+
.catch((e) => {
|
|
117
436
|
UI.showError(e.message);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
437
|
+
if (applyBtn) applyBtn.disabled = false;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function discardPending() {
|
|
442
|
+
for (const ch of pendingChanges.values()) {
|
|
443
|
+
renderCellValue(ch.td, ch.origNull ? null : ch.origValue);
|
|
444
|
+
ch.td.classList.remove('cell-dirty');
|
|
445
|
+
delete ch.td.dataset.origValue;
|
|
446
|
+
delete ch.td.dataset.origNull;
|
|
447
|
+
}
|
|
448
|
+
pendingChanges.clear();
|
|
449
|
+
updateChangesBar();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function buildChangesBar() {
|
|
453
|
+
const bar = document.createElement('div');
|
|
454
|
+
bar.id = 'changes-bar';
|
|
455
|
+
bar.className =
|
|
456
|
+
'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-2xl border border-base-300 bg-base-100 px-4 py-2.5 shadow-lift hidden';
|
|
457
|
+
bar.innerHTML =
|
|
458
|
+
'<span class="text-sm font-medium text-base-content/80"><b id="changes-count" class="tabular-nums text-primary">0</b> pending change(s)</span>' +
|
|
459
|
+
'<span class="hidden text-sm text-base-content/40 sm:inline">edit cells, then apply</span>' +
|
|
460
|
+
'<div class="flex items-center gap-1.5">' +
|
|
461
|
+
'<button type="button" id="changes-apply" class="btn btn-primary btn-sm gap-1">' +
|
|
462
|
+
'<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>' +
|
|
463
|
+
'Apply</button>' +
|
|
464
|
+
'<button type="button" id="changes-discard" class="btn btn-ghost btn-sm">Discard</button>' +
|
|
465
|
+
'</div>';
|
|
466
|
+
document.body.appendChild(bar);
|
|
467
|
+
return bar;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let changesBar = null;
|
|
471
|
+
let changesCount = null;
|
|
472
|
+
let applyBtn = null;
|
|
473
|
+
|
|
474
|
+
function beginEdit(td) {
|
|
475
|
+
if (readonly) return;
|
|
476
|
+
if (td.classList.contains('js-editing')) return;
|
|
477
|
+
const tr = td.closest('tr');
|
|
478
|
+
if (!tr || !tr.dataset.pk || !tr.dataset.table) return;
|
|
479
|
+
const c = colInfo(td.dataset.col);
|
|
480
|
+
if (!c) return;
|
|
481
|
+
|
|
482
|
+
// Stage any currently-open editor first — same as blur would.
|
|
483
|
+
const open = document.querySelector('.js-editing');
|
|
484
|
+
if (open && open._finish) open._finish(true);
|
|
485
|
+
|
|
486
|
+
td._origNull = td.dataset.null === 'true';
|
|
487
|
+
td._origValue = currentCellValue(td);
|
|
488
|
+
|
|
489
|
+
const el = createEditor(td);
|
|
490
|
+
if (!el) return;
|
|
491
|
+
td.classList.add('js-editing');
|
|
492
|
+
td.innerHTML = '';
|
|
493
|
+
td.appendChild(el);
|
|
494
|
+
|
|
495
|
+
let committed = false;
|
|
496
|
+
const finish = (commit) => {
|
|
497
|
+
if (committed) return;
|
|
498
|
+
committed = true;
|
|
499
|
+
if (commit) stageCell(tr, td, el);
|
|
500
|
+
else restoreCell(td);
|
|
501
|
+
};
|
|
502
|
+
td._finish = finish;
|
|
503
|
+
|
|
504
|
+
el.addEventListener('keydown', (e) => {
|
|
505
|
+
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
|
506
|
+
else if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
|
507
|
+
});
|
|
508
|
+
if (el.tagName === 'SELECT') {
|
|
509
|
+
el.addEventListener('change', () => finish(true));
|
|
510
|
+
} else if (el.type === 'checkbox') {
|
|
511
|
+
el.addEventListener('change', () => finish(true));
|
|
512
|
+
} else {
|
|
513
|
+
el.addEventListener('blur', () => finish(true));
|
|
514
|
+
}
|
|
515
|
+
el.focus();
|
|
516
|
+
if (typeof el.select === 'function') el.select();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function initInlineEditing() {
|
|
520
|
+
if (readonly) return;
|
|
521
|
+
changesBar = buildChangesBar();
|
|
522
|
+
changesCount = document.getElementById('changes-count');
|
|
523
|
+
applyBtn = document.getElementById('changes-apply');
|
|
524
|
+
const discardBtn = document.getElementById('changes-discard');
|
|
525
|
+
if (applyBtn) applyBtn.addEventListener('click', applyPending);
|
|
526
|
+
if (discardBtn) discardBtn.addEventListener('click', discardPending);
|
|
527
|
+
|
|
528
|
+
document.querySelectorAll('.js-inline-cell').forEach((td) => {
|
|
529
|
+
td.addEventListener('dblclick', () => beginEdit(td));
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---- Bulk row operations ------------------------------------------------
|
|
534
|
+
|
|
535
|
+
function selectedRows() {
|
|
536
|
+
return Array.prototype.slice
|
|
537
|
+
.call(document.querySelectorAll('.row-checkbox:checked'))
|
|
538
|
+
.map((cb) => cb.value)
|
|
539
|
+
.filter(Boolean);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function initBulk() {
|
|
543
|
+
const bulkBar = document.getElementById('bulk-bar');
|
|
544
|
+
if (!bulkBar) return;
|
|
545
|
+
const selectAll = document.getElementById('select-all-rows');
|
|
546
|
+
const countEl = document.getElementById('bulk-count');
|
|
547
|
+
const deleteBtn = document.getElementById('bulk-delete');
|
|
548
|
+
const exportCsv = document.getElementById('bulk-export-csv');
|
|
549
|
+
const exportJson = document.getElementById('bulk-export-json');
|
|
550
|
+
const clearBtn = document.getElementById('bulk-clear');
|
|
551
|
+
const checkboxes = Array.prototype.slice.call(document.querySelectorAll('.row-checkbox'));
|
|
552
|
+
|
|
553
|
+
function update() {
|
|
554
|
+
const ids = selectedRows();
|
|
555
|
+
const n = ids.length;
|
|
556
|
+
if (countEl) countEl.textContent = String(n);
|
|
557
|
+
bulkBar.classList.toggle('hidden', n === 0);
|
|
558
|
+
if (selectAll) selectAll.checked = n > 0 && n === checkboxes.length;
|
|
559
|
+
if (deleteBtn) deleteBtn.disabled = readonly || n === 0;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
checkboxes.forEach((cb) => cb.addEventListener('change', update));
|
|
563
|
+
if (selectAll) {
|
|
564
|
+
selectAll.addEventListener('change', () => {
|
|
565
|
+
checkboxes.forEach((cb) => { cb.checked = selectAll.checked; });
|
|
566
|
+
update();
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (clearBtn) clearBtn.addEventListener('click', () => {
|
|
570
|
+
checkboxes.forEach((cb) => { cb.checked = false; });
|
|
571
|
+
update();
|
|
572
|
+
});
|
|
573
|
+
if (exportCsv) exportCsv.addEventListener('click', () => bulkExport('csv'));
|
|
574
|
+
if (exportJson) exportJson.addEventListener('click', () => bulkExport('json'));
|
|
575
|
+
if (deleteBtn) deleteBtn.addEventListener('click', bulkDelete);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function bulkExport(format) {
|
|
579
|
+
const ids = selectedRows();
|
|
580
|
+
if (!ids.length) return;
|
|
581
|
+
try {
|
|
582
|
+
const data = await Api.post('/api/tables/' + t + '/rows/bulk-export', { ids, format });
|
|
583
|
+
const blob = new Blob([data.content], {
|
|
584
|
+
type: format === 'json' ? 'application/json;charset=utf-8' : 'text/csv;charset=utf-8',
|
|
585
|
+
});
|
|
586
|
+
const a = document.createElement('a');
|
|
587
|
+
a.href = URL.createObjectURL(blob);
|
|
588
|
+
a.download = data.filename;
|
|
589
|
+
document.body.appendChild(a);
|
|
590
|
+
a.click();
|
|
591
|
+
a.remove();
|
|
592
|
+
URL.revokeObjectURL(a.href);
|
|
593
|
+
UI.showToast('Exported ' + data.rowCount + ' row(s).', 'success');
|
|
594
|
+
} catch (e) {
|
|
595
|
+
UI.showError(e.message);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function bulkDelete() {
|
|
600
|
+
const ids = selectedRows();
|
|
601
|
+
if (!ids.length || readonly) return;
|
|
602
|
+
let impact;
|
|
603
|
+
try {
|
|
604
|
+
impact = await Api.post('/api/tables/' + t + '/rows/bulk-impact', { ids });
|
|
605
|
+
} catch (e) {
|
|
606
|
+
UI.showError(e.message);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const refs = (impact && impact.references) || [];
|
|
610
|
+
const details = refs.length
|
|
611
|
+
? '<p class="mb-2 font-medium text-warning">⚠ Other tables reference these records:</p>' +
|
|
612
|
+
'<ul class="mb-3 list-disc space-y-1 pl-5">' +
|
|
613
|
+
refs.map((r) =>
|
|
614
|
+
'<li><code class="font-mono">' + escapeHtml(r.table) + '.' + escapeHtml(r.from) + '</code> — <b class="tabular-nums">' + r.count + '</b> row(s) point at them</li>'
|
|
615
|
+
).join('') +
|
|
616
|
+
'</ul>' +
|
|
617
|
+
'<p class="text-xs text-base-content/55">Depending on the foreign-key action, those rows may be deleted or orphaned. Deleting the selected rows can fail if a constraint blocks it.</p>'
|
|
618
|
+
: '<p class="text-base-content/60">No other tables reference these rows — a clean delete.</p>';
|
|
619
|
+
|
|
620
|
+
const message =
|
|
621
|
+
'<p class="mb-3">Delete <b class="tabular-nums">' + ids.length + '</b> selected row(s) from <code class="font-mono">' + escapeHtml(cfg.table) + '</code>? This cannot be undone.</p>' +
|
|
622
|
+
details;
|
|
623
|
+
|
|
624
|
+
showConfirmModal({
|
|
625
|
+
title: 'Delete selected rows',
|
|
626
|
+
message: message,
|
|
627
|
+
confirmLabel: 'Delete ' + ids.length + ' row(s)',
|
|
628
|
+
danger: true,
|
|
629
|
+
onConfirm: async () => {
|
|
630
|
+
try {
|
|
631
|
+
const data = await Api.post('/api/tables/' + t + '/rows/bulk-delete', { ids, confirmImpact: true });
|
|
632
|
+
UI.showToast(data.message || 'Rows deleted.', 'success');
|
|
633
|
+
window.location.reload();
|
|
634
|
+
} catch (e) {
|
|
635
|
+
UI.showError(e.message);
|
|
636
|
+
}
|
|
637
|
+
},
|
|
121
638
|
});
|
|
122
639
|
}
|
|
640
|
+
|
|
641
|
+
function showConfirmModal(opts) {
|
|
642
|
+
const overlay = document.createElement('div');
|
|
643
|
+
overlay.className = 'fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4';
|
|
644
|
+
const card = document.createElement('div');
|
|
645
|
+
card.className = 'app-card w-full max-w-lg overflow-hidden';
|
|
646
|
+
card.innerHTML =
|
|
647
|
+
'<div class="border-b border-base-200 px-5 py-4"><h3 class="text-base font-semibold">' + escapeHtml(opts.title) + '</h3></div>' +
|
|
648
|
+
'<div class="max-h-[55vh] overflow-auto px-5 py-4 text-sm leading-relaxed">' + opts.message + '</div>' +
|
|
649
|
+
'<div class="flex flex-wrap justify-end gap-2 border-t border-base-200 px-5 py-3">' +
|
|
650
|
+
'<button type="button" class="btn btn-ghost btn-sm js-modal-cancel">Cancel</button>' +
|
|
651
|
+
'<button type="button" class="btn btn-sm ' + (opts.danger ? 'btn-error' : 'btn-primary') + ' js-modal-ok">' + escapeHtml(opts.confirmLabel || 'Confirm') + '</button>' +
|
|
652
|
+
'</div>';
|
|
653
|
+
overlay.appendChild(card);
|
|
654
|
+
document.body.appendChild(overlay);
|
|
655
|
+
|
|
656
|
+
const onKey = (e) => { if (e.key === 'Escape') close(false); };
|
|
657
|
+
const close = (result) => {
|
|
658
|
+
document.removeEventListener('keydown', onKey);
|
|
659
|
+
overlay.remove();
|
|
660
|
+
if (result && opts.onConfirm) opts.onConfirm();
|
|
661
|
+
};
|
|
662
|
+
document.addEventListener('keydown', onKey);
|
|
663
|
+
overlay.querySelector('.js-modal-cancel').addEventListener('click', () => close(false));
|
|
664
|
+
overlay.querySelector('.js-modal-ok').addEventListener('click', () => close(true));
|
|
665
|
+
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
|
|
666
|
+
const okBtn = overlay.querySelector('.js-modal-ok');
|
|
667
|
+
if (okBtn) okBtn.focus();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---- CSV import ---------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
function initImport() {
|
|
673
|
+
const importBtn = document.getElementById('import-csv');
|
|
674
|
+
const importPanel = document.getElementById('import-panel');
|
|
675
|
+
const importClose = document.getElementById('import-csv-close');
|
|
676
|
+
const importText = document.getElementById('import-csv-text');
|
|
677
|
+
const importFile = document.getElementById('import-csv-file');
|
|
678
|
+
const importSubmit = document.getElementById('import-csv-submit');
|
|
679
|
+
|
|
680
|
+
if (importBtn && importPanel) {
|
|
681
|
+
importBtn.addEventListener('click', () => {
|
|
682
|
+
importPanel.classList.toggle('hidden');
|
|
683
|
+
if (!importPanel.classList.contains('hidden')) importText.focus();
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (importClose) {
|
|
687
|
+
importClose.addEventListener('click', () => importPanel.classList.add('hidden'));
|
|
688
|
+
}
|
|
689
|
+
if (importFile) {
|
|
690
|
+
importFile.addEventListener('change', () => {
|
|
691
|
+
const file = importFile.files && importFile.files[0];
|
|
692
|
+
if (!file) return;
|
|
693
|
+
const reader = new FileReader();
|
|
694
|
+
reader.onload = () => { importText.value = String(reader.result || ''); };
|
|
695
|
+
reader.readAsText(file);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
if (importSubmit && importText) {
|
|
699
|
+
importSubmit.addEventListener('click', async () => {
|
|
700
|
+
const csv = importText.value.trim();
|
|
701
|
+
if (!csv) return UI.showError('Paste or choose a CSV file first.');
|
|
702
|
+
const m = window.location.pathname.match(/\/tables\/([^/]+)(?:\/|$)/);
|
|
703
|
+
const table = m ? decodeURIComponent(m[1]) : '';
|
|
704
|
+
if (!table) return UI.showError('Could not determine the table.');
|
|
705
|
+
importSubmit.disabled = true;
|
|
706
|
+
try {
|
|
707
|
+
const data = await Api.post('/api/tables/' + encodeURIComponent(table) + '/rows/import', { csv });
|
|
708
|
+
UI.showToast(data.message || 'Import complete.', 'success');
|
|
709
|
+
window.location.reload();
|
|
710
|
+
} catch (e) {
|
|
711
|
+
UI.showError(e.message);
|
|
712
|
+
} finally {
|
|
713
|
+
importSubmit.disabled = false;
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ---- Related rows (Neon-style per-row expander) -------------------------
|
|
720
|
+
|
|
721
|
+
function rotateChevron(btn, open) {
|
|
722
|
+
const chev = btn.querySelector('.js-ref-chevron');
|
|
723
|
+
if (chev) chev.classList.toggle('rotate-180', open);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
let refOpenBtn = null;
|
|
727
|
+
|
|
728
|
+
function closeRefPop() {
|
|
729
|
+
const pop = document.querySelector('.js-ref-pop');
|
|
730
|
+
if (pop) {
|
|
731
|
+
if (pop._cleanup) pop._cleanup();
|
|
732
|
+
pop.remove();
|
|
733
|
+
}
|
|
734
|
+
if (refOpenBtn) {
|
|
735
|
+
rotateChevron(refOpenBtn, false);
|
|
736
|
+
refOpenBtn.setAttribute('aria-expanded', 'false');
|
|
737
|
+
refOpenBtn.classList.remove('opacity-50');
|
|
738
|
+
refOpenBtn = null;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function renderMiniTable(ref, basePath) {
|
|
743
|
+
const href = basePath + '/tables/' + encodeURIComponent(ref.table);
|
|
744
|
+
let html =
|
|
745
|
+
'<div class="flex items-center justify-between gap-2 border-b border-base-200 px-3 py-2">' +
|
|
746
|
+
'<span class="min-w-0 truncate">' +
|
|
747
|
+
'<a class="link link-primary font-mono text-xs font-semibold" href="' + href + '">' + escapeHtml(ref.table) + '</a>' +
|
|
748
|
+
'<span class="ml-1.5 text-xs text-base-content/45">via <code class="font-mono">' + escapeHtml(ref.from) + '</code> = <code class="font-mono">' + escapeHtml(String(ref.value)) + '</code></span>' +
|
|
749
|
+
'</span>' +
|
|
750
|
+
'<span class="chip ' + (ref.count ? 'chip-pk' : 'chip-type') + '">' + escapeHtml(ref.count + ' row' + (ref.count === 1 ? '' : 's')) + '</span>' +
|
|
751
|
+
'</div>';
|
|
752
|
+
|
|
753
|
+
if (!ref.rows.length) {
|
|
754
|
+
return html + '<div class="px-3 py-3 text-xs text-base-content/45">No matching rows.</div>';
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const thead = '<tr>' + ref.columns.map(function (c) {
|
|
758
|
+
return '<th class="whitespace-nowrap px-2.5 py-1.5 text-left text-[10px] font-semibold uppercase tracking-wide text-base-content/50"><span class="font-mono normal-case">' + escapeHtml(c) + '</span></th>';
|
|
759
|
+
}).join('') + '</tr>';
|
|
760
|
+
const tbody = ref.rows.map(function (r) {
|
|
761
|
+
return '<tr class="border-t border-base-200/60 hover:bg-primary/[0.04]">' + ref.columns.map(function (c) {
|
|
762
|
+
const v = r[c];
|
|
763
|
+
if (v === null || v === undefined) return '<td class="px-2.5 py-1.5 align-middle"><span class="text-[11px] text-base-content/40">NULL</span></td>';
|
|
764
|
+
const s = String(v);
|
|
765
|
+
const short = s.length > 120 ? s.slice(0, 120) + '…' : s;
|
|
766
|
+
return '<td class="max-w-[180px] truncate px-2.5 py-1.5 align-middle font-mono text-[11px] text-base-content/80" title="' + escapeHtml(s) + '">' + escapeHtml(short) + '</td>';
|
|
767
|
+
}).join('') + '</tr>';
|
|
768
|
+
}).join('');
|
|
769
|
+
|
|
770
|
+
return html +
|
|
771
|
+
'<div class="max-h-64 overflow-auto">' +
|
|
772
|
+
'<table class="w-full"><thead><tr class="bg-base-200/40">' + thead + '</tr></thead>' +
|
|
773
|
+
'<tbody>' + tbody + '</tbody></table>' +
|
|
774
|
+
'</div>' +
|
|
775
|
+
'<div class="flex justify-end border-t border-base-200 px-3 py-1.5">' +
|
|
776
|
+
'<a class="btn btn-ghost btn-xs text-base-content/60 hover:text-primary" href="' + href + '">Open table</a>' +
|
|
777
|
+
'</div>';
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function showRefPop(btn, refs) {
|
|
781
|
+
closeRefPop();
|
|
782
|
+
const pop = document.createElement('div');
|
|
783
|
+
pop.className = 'js-ref-pop app-card js-ref-anim z-50 min-w-[280px] max-w-[460px] overflow-hidden shadow-lift';
|
|
784
|
+
pop.innerHTML = refs.length
|
|
785
|
+
? refs.map((ref) => renderMiniTable(ref, base)).join('<div class="h-px bg-base-200"></div>')
|
|
786
|
+
: '<div class="px-4 py-3 text-sm text-base-content/45">No related rows for this record.</div>';
|
|
787
|
+
|
|
788
|
+
pop.style.visibility = 'hidden';
|
|
789
|
+
document.body.appendChild(pop);
|
|
790
|
+
const pad = 8;
|
|
791
|
+
const popW = Math.min(460, Math.max(280, pop.offsetWidth));
|
|
792
|
+
const popH = pop.offsetHeight;
|
|
793
|
+
|
|
794
|
+
const rect = btn.getBoundingClientRect();
|
|
795
|
+
let left = rect.left;
|
|
796
|
+
if (left + popW > window.innerWidth - pad) left = Math.max(pad, window.innerWidth - popW - pad);
|
|
797
|
+
let top = rect.bottom + 6;
|
|
798
|
+
if (top + popH > window.innerHeight - pad) top = Math.max(pad, rect.top - popH - 6);
|
|
799
|
+
|
|
800
|
+
pop.style.position = 'fixed';
|
|
801
|
+
pop.style.left = left + 'px';
|
|
802
|
+
pop.style.top = top + 'px';
|
|
803
|
+
pop.style.visibility = 'visible';
|
|
804
|
+
refOpenBtn = btn;
|
|
805
|
+
|
|
806
|
+
// Close when the page behind the popover scrolls or the window resizes,
|
|
807
|
+
// but NOT when the nested table's own scroll container is scrolled — the
|
|
808
|
+
// capture-phase listener fires for every scroll target, including the
|
|
809
|
+
// mini-table inside the popover, which would otherwise close it instantly.
|
|
810
|
+
const onViewChange = (e) => {
|
|
811
|
+
if (e && e.target && pop.contains(e.target)) return;
|
|
812
|
+
closeRefPop();
|
|
813
|
+
};
|
|
814
|
+
window.addEventListener('scroll', onViewChange, true);
|
|
815
|
+
window.addEventListener('resize', onViewChange);
|
|
816
|
+
const onClickOutside = (e) => {
|
|
817
|
+
if (!pop.contains(e.target) && e.target !== btn && !btn.contains(e.target)) closeRefPop();
|
|
818
|
+
};
|
|
819
|
+
document.addEventListener('click', onClickOutside, true);
|
|
820
|
+
const onKey = (e) => { if (e.key === 'Escape') closeRefPop(); };
|
|
821
|
+
document.addEventListener('keydown', onKey);
|
|
822
|
+
|
|
823
|
+
pop._cleanup = () => {
|
|
824
|
+
window.removeEventListener('scroll', onViewChange, true);
|
|
825
|
+
window.removeEventListener('resize', onViewChange);
|
|
826
|
+
document.removeEventListener('click', onClickOutside, true);
|
|
827
|
+
document.removeEventListener('keydown', onKey);
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function initRefToggles() {
|
|
832
|
+
document.querySelectorAll('.js-ref-toggle').forEach((btn) => {
|
|
833
|
+
btn.addEventListener('click', (e) => {
|
|
834
|
+
e.stopPropagation();
|
|
835
|
+
const open = btn.getAttribute('aria-expanded') === 'true';
|
|
836
|
+
if (open) {
|
|
837
|
+
closeRefPop();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const table = btn.dataset.table;
|
|
841
|
+
const pk = btn.dataset.pk;
|
|
842
|
+
if (!table || !pk) return;
|
|
843
|
+
btn.classList.add('opacity-50');
|
|
844
|
+
Api.get('/api/tables/' + encodeURIComponent(table) + '/rows/' + encodeURIComponent(pk) + '/references')
|
|
845
|
+
.then((data) => {
|
|
846
|
+
btn.classList.remove('opacity-50');
|
|
847
|
+
const refTable = btn.dataset.refTable;
|
|
848
|
+
const refs = ((data && data.references) || []).filter((r) => !refTable || r.table === refTable);
|
|
849
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
850
|
+
rotateChevron(btn, true);
|
|
851
|
+
showRefPop(btn, refs);
|
|
852
|
+
})
|
|
853
|
+
.catch((e) => {
|
|
854
|
+
btn.classList.remove('opacity-50');
|
|
855
|
+
UI.showError(e.message);
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ---- Init ---------------------------------------------------------------
|
|
862
|
+
|
|
863
|
+
async function init() {
|
|
864
|
+
try {
|
|
865
|
+
info = await Api.get('/api/tables/' + t + '/info');
|
|
866
|
+
} catch (e) {
|
|
867
|
+
info = null;
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
fkOptions = await Api.get('/api/tables/' + t + '/fk-options');
|
|
871
|
+
} catch (e) {
|
|
872
|
+
fkOptions = {};
|
|
873
|
+
}
|
|
874
|
+
initFilters();
|
|
875
|
+
initInlineEditing();
|
|
876
|
+
initBulk();
|
|
877
|
+
initRowDelete();
|
|
878
|
+
initPageSize();
|
|
879
|
+
initImport();
|
|
880
|
+
initRefToggles();
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
init();
|
|
123
884
|
})();
|