pgkiln 0.46.0 → 0.47.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/db/migrations/108_sql_history.sql +25 -0
- package/docs/apex-feature-parity.md +28 -2
- package/docs/guide/03-builder.md +89 -18
- package/docs/guide/08-security.md +1 -1
- package/docs/guide/10-tutorial.md +1 -1
- package/docs/guide/12-development.md +20 -2
- package/docs/guide/16-files.md +2 -2
- package/package.json +1 -1
- package/public/worksheet.css +244 -0
- package/public/worksheet.js +665 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/object-actions.ts +166 -0
- package/src/builder/object-browser.ts +40 -25
- package/src/builder/querybuilder.ts +1 -1
- package/src/builder/sampledata.ts +2 -2
- package/src/builder/scripts.ts +3 -3
- package/src/builder/sql.ts +15 -73
- package/src/builder/table-editor.ts +219 -0
- package/src/builder/ui.ts +3 -3
- package/src/builder/unload.ts +8 -4
- package/src/builder/worksheet.ts +569 -0
- package/src/builder/workspaces.ts +1 -1
- package/src/sqlformat.ts +208 -0
- package/src/sqlplan.ts +133 -0
- package/src/sqlscript.ts +6 -2
- package/src/sqlworksheet.ts +278 -0
- package/src/tabledef.ts +226 -0
- package/src/unload.ts +14 -3
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// pgkiln builder: the SQL Worksheet (sprint 91, src/builder/worksheet.ts).
|
|
2
|
+
//
|
|
3
|
+
// Progressive enhancement of one form: without JavaScript its buttons post it and the panes stack.
|
|
4
|
+
// - Run Statement (Ctrl+Enter), Run Script (F5), Explain Plan (F10), Autotrace (F6): the cursor or the
|
|
5
|
+
// selection goes along (sel_start / sel_end), so the server runs the statement at the cursor
|
|
6
|
+
// - after a run the statement that ran is selected again; an error puts the cursor on its line
|
|
7
|
+
// - output tabs, the navigator's schema / type (swapped in without a reload) and search
|
|
8
|
+
// - tables and views: drag into the editor for a SELECT, or the SELECT / INSERT / UPDATE / DELETE buttons
|
|
9
|
+
// - resizable panes (mouse, touch or arrow keys on the splitters), a navigator that hides, fullscreen;
|
|
10
|
+
// sizes and the hidden navigator are remembered in this browser
|
|
11
|
+
// - (sprint 92) Query Result: sort by a column, copy as tab-separated text, a cell's whole value in a dialog;
|
|
12
|
+
// SQL History: search, filter by status, run again, copy
|
|
13
|
+
// - (sprint 93) worksheets as tabs kept in this browser (unsaved text survives a reload), saved as SQL Scripts
|
|
14
|
+
// (Ctrl+S), the Files list; bind and substitution values in a dialog; Format (Ctrl+F7), Convert case (Ctrl+B),
|
|
15
|
+
// find and replace (Ctrl+F, Ctrl+H), go to line (Ctrl+G), comment lines (Ctrl+/), Help (F1)
|
|
16
|
+
// Strict CSP: no inline styles; sizes are set through the CSSOM.
|
|
17
|
+
(() => {
|
|
18
|
+
'use strict';
|
|
19
|
+
const form = document.getElementById('ws');
|
|
20
|
+
if (!form) return;
|
|
21
|
+
const ta = form.querySelector('textarea[name=sql]');
|
|
22
|
+
const out = form.querySelector('.ws-output');
|
|
23
|
+
const store = {
|
|
24
|
+
get(k) { try { return localStorage.getItem(`pgkiln.ws.${k}`); } catch { return null; } },
|
|
25
|
+
set(k, v) { try { localStorage.setItem(`pgkiln.ws.${k}`, v); } catch { /* private mode */ } },
|
|
26
|
+
};
|
|
27
|
+
for (const el of form.querySelectorAll('[hidden]:not(.ws-busy):not(.ws-findbar)')) el.hidden = false;
|
|
28
|
+
|
|
29
|
+
// ------------------------------------------------------------ runs
|
|
30
|
+
const button = (mode) => form.querySelector(`button[name=mode][value=${mode}]`);
|
|
31
|
+
const run = (mode) => form.requestSubmit(button(mode));
|
|
32
|
+
form.addEventListener('submit', (e) => {
|
|
33
|
+
const mode = e.submitter?.value;
|
|
34
|
+
if (mode === 'nav' || mode === 'save' || mode === 'save_as' || mode === 'rename' || mode === 'download') return;
|
|
35
|
+
form.elements.sel_start.value = String(ta.selectionStart);
|
|
36
|
+
form.elements.sel_end.value = String(ta.selectionEnd);
|
|
37
|
+
const busy = form.querySelector('.ws-busy');
|
|
38
|
+
if (busy) busy.hidden = false;
|
|
39
|
+
});
|
|
40
|
+
const KEYS = { F5: 'script', F10: 'explain', F6: 'autotrace' };
|
|
41
|
+
document.addEventListener('keydown', (e) => {
|
|
42
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !e.altKey && (e.target === ta || form.contains(e.target))) {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
run('statement');
|
|
46
|
+
} else if (KEYS[e.key] && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && !document.querySelector('dialog[open]')) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
run(KEYS[e.key]);
|
|
49
|
+
} else if (e.key === 'Escape' && document.body.classList.contains('ws-fullscreen') && !document.querySelector('dialog[open]')) {
|
|
50
|
+
fullscreen(false);
|
|
51
|
+
} else if (handleKey(e)) {
|
|
52
|
+
e.preventDefault();
|
|
53
|
+
e.stopPropagation();
|
|
54
|
+
}
|
|
55
|
+
}, true);
|
|
56
|
+
// (sprint 93) the editing keys; true: handled
|
|
57
|
+
const handleKey = (e) => {
|
|
58
|
+
const ctrl = (e.ctrlKey || e.metaKey) && !e.altKey;
|
|
59
|
+
const inEditor = e.target === ta;
|
|
60
|
+
if (e.key === 'F1' && !ctrl) { form.querySelector('[data-ws-help]')?.click(); return true; }
|
|
61
|
+
const bar = form.querySelector('.ws-findbar');
|
|
62
|
+
if (e.key === 'Escape' && !bar.hidden && inEditor) { bar.hidden = true; return true; }
|
|
63
|
+
if (!ctrl) return false;
|
|
64
|
+
const k = e.key.toLowerCase();
|
|
65
|
+
if (k === 's' && !e.shiftKey) { save(); return true; }
|
|
66
|
+
if (e.key === 'F7') { format(); return true; }
|
|
67
|
+
if (!inEditor && !e.target.closest?.('.ws-findbar')) return false;
|
|
68
|
+
if (k === 'b' && inEditor) { convertCase(); return true; }
|
|
69
|
+
if (k === 'f' || k === 'h' || k === 'g') { openFind(k); return true; }
|
|
70
|
+
if (e.key === '/' && inEditor) { commentLines(); return true; }
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// the statement that ran, or the line of the first error
|
|
75
|
+
const ran = (form.dataset.ran || '').split(',').map(Number);
|
|
76
|
+
const errLine = Number(form.dataset.errorLine);
|
|
77
|
+
if (errLine > 0) {
|
|
78
|
+
const lines = ta.value.split('\n');
|
|
79
|
+
const at = lines.slice(0, errLine - 1).reduce((n, l) => n + l.length + 1, 0);
|
|
80
|
+
ta.focus();
|
|
81
|
+
ta.setSelectionRange(at, at + (lines[errLine - 1] || '').length);
|
|
82
|
+
} else if (ran.length === 2 && ran[1] > ran[0]) {
|
|
83
|
+
ta.focus();
|
|
84
|
+
ta.setSelectionRange(ran[0], ran[1]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ------------------------------------------------------------ output tabs
|
|
88
|
+
const tabs = [...out.querySelectorAll('[role=tab]')];
|
|
89
|
+
const show = (key, focus) => {
|
|
90
|
+
for (const t of tabs) {
|
|
91
|
+
const on = t.dataset.wsTab === key;
|
|
92
|
+
t.setAttribute('aria-selected', String(on));
|
|
93
|
+
t.tabIndex = on ? 0 : -1;
|
|
94
|
+
if (on && focus) t.focus();
|
|
95
|
+
}
|
|
96
|
+
for (const p of out.querySelectorAll('.ws-pane')) p.hidden = p.dataset.pane !== key;
|
|
97
|
+
out.dataset.active = key;
|
|
98
|
+
};
|
|
99
|
+
show(out.dataset.active || 'result');
|
|
100
|
+
out.querySelector('[role=tablist]').addEventListener('click', (e) => {
|
|
101
|
+
const t = e.target.closest('[role=tab]');
|
|
102
|
+
if (t) show(t.dataset.wsTab);
|
|
103
|
+
});
|
|
104
|
+
out.querySelector('[role=tablist]').addEventListener('keydown', (e) => {
|
|
105
|
+
const k = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
|
|
106
|
+
const next = { ArrowRight: k + 1, ArrowLeft: k - 1, Home: 0, End: tabs.length - 1 }[e.key];
|
|
107
|
+
if (next === undefined) return;
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
show(tabs[(next + tabs.length) % tabs.length].dataset.wsTab, true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ------------------------------------------------------------ inserting text into the editor
|
|
113
|
+
const insert = (text) => {
|
|
114
|
+
ta.focus();
|
|
115
|
+
const s = ta.selectionStart, en = ta.selectionEnd;
|
|
116
|
+
const before = ta.value.slice(0, s);
|
|
117
|
+
const pad = before && !before.endsWith('\n\n') ? (before.endsWith('\n') ? '\n' : '\n\n') : '';
|
|
118
|
+
ta.setRangeText(`${pad}${text};\n`, s, en, 'end');
|
|
119
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
120
|
+
};
|
|
121
|
+
form.addEventListener('click', (e) => {
|
|
122
|
+
const b = e.target.closest('[data-ws-insert]');
|
|
123
|
+
if (b) insert(b.dataset.wsInsert);
|
|
124
|
+
const h = e.target.closest('[data-ws-history]');
|
|
125
|
+
if (h && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
insert(h.dataset.wsHistory);
|
|
128
|
+
}
|
|
129
|
+
if (e.target.closest('[data-ws-clear]')) {
|
|
130
|
+
ta.value = '';
|
|
131
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
132
|
+
ta.focus();
|
|
133
|
+
}
|
|
134
|
+
if (e.target.closest('[data-ws-toggle-nav]')) toggleNav();
|
|
135
|
+
if (e.target.closest('[data-ws-fullscreen]')) fullscreen(!document.body.classList.contains('ws-fullscreen'));
|
|
136
|
+
});
|
|
137
|
+
form.addEventListener('dragstart', (e) => {
|
|
138
|
+
const sum = e.target.closest?.('[data-ws-sql]');
|
|
139
|
+
if (!sum) return;
|
|
140
|
+
e.dataTransfer.setData('text/plain', `${sum.dataset.wsSql};\n`);
|
|
141
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------------ (sprint 92) Query Result and SQL History
|
|
145
|
+
const copy = async (text, what) => {
|
|
146
|
+
try {
|
|
147
|
+
await navigator.clipboard.writeText(text);
|
|
148
|
+
announce(`${what} copied.`);
|
|
149
|
+
} catch {
|
|
150
|
+
announce('Copying is not allowed here.');
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const announce = (msg) => {
|
|
154
|
+
const last = form.querySelector('.ws-last');
|
|
155
|
+
if (last) last.textContent = msg;
|
|
156
|
+
};
|
|
157
|
+
const grid = form.querySelector('[data-ws-grid]');
|
|
158
|
+
if (grid) {
|
|
159
|
+
const cellValue = (td) => (td.querySelector('.null') ? '' : td.textContent);
|
|
160
|
+
form.addEventListener('click', (e) => {
|
|
161
|
+
if (e.target.closest('[data-ws-copy]')) {
|
|
162
|
+
const head = [...grid.tHead.rows[0].cells].slice(1).map((th) => th.textContent.trim());
|
|
163
|
+
const rows = [...grid.tBodies[0].rows].map((tr) => [...tr.cells].slice(1).map((td) => cellValue(td).replace(/[\t\n]/g, ' ')));
|
|
164
|
+
copy([head, ...rows].map((r) => r.join('\t')).join('\n'), `${rows.length} rows`);
|
|
165
|
+
}
|
|
166
|
+
const sort = e.target.closest('[data-ws-sort]');
|
|
167
|
+
if (sort) {
|
|
168
|
+
const k = Number(sort.dataset.wsSort) + 1;
|
|
169
|
+
const th = sort.closest('th');
|
|
170
|
+
const asc = th.getAttribute('aria-sort') !== 'ascending';
|
|
171
|
+
for (const x of grid.tHead.rows[0].cells) x.removeAttribute('aria-sort');
|
|
172
|
+
th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
|
|
173
|
+
const num = th.classList.contains('num');
|
|
174
|
+
const key = (tr) => { const td = tr.cells[k]; return td.querySelector('.null') ? null : num ? Number(td.textContent) : td.textContent; };
|
|
175
|
+
const rows = [...grid.tBodies[0].rows].sort((a, b) => {
|
|
176
|
+
const x = key(a), y = key(b);
|
|
177
|
+
if (x === y) return 0;
|
|
178
|
+
if (x === null) return 1; // nulls last either way
|
|
179
|
+
if (y === null) return -1;
|
|
180
|
+
return (num ? x - y : String(x).localeCompare(String(y))) * (asc ? 1 : -1);
|
|
181
|
+
});
|
|
182
|
+
grid.tBodies[0].append(...rows);
|
|
183
|
+
}
|
|
184
|
+
const td = e.target.closest('[data-ws-grid] td:not(.ws-rownum)');
|
|
185
|
+
const dlg = document.getElementById('ws-cell');
|
|
186
|
+
if (td && dlg && !td.querySelector('.null')) {
|
|
187
|
+
let v = td.textContent;
|
|
188
|
+
if (/^\s*[[{]/.test(v)) { try { v = JSON.stringify(JSON.parse(v), null, 2); } catch { /* not JSON */ } }
|
|
189
|
+
const col = grid.tHead.rows[0].cells[td.cellIndex].textContent.trim();
|
|
190
|
+
dlg.querySelector('#ws-cell-title').textContent = `${col}, row ${td.parentElement.cells[0].textContent}`;
|
|
191
|
+
dlg.querySelector('pre').textContent = v;
|
|
192
|
+
dlg.showModal();
|
|
193
|
+
}
|
|
194
|
+
if (e.target.closest('[data-ws-cell-close]')) e.target.closest('dialog').close();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const histQ = form.querySelector('[data-ws-hist-q]');
|
|
198
|
+
const histS = form.querySelector('[data-ws-hist-s]');
|
|
199
|
+
const filterHistory = () => {
|
|
200
|
+
const q = histQ.value.trim().toLowerCase();
|
|
201
|
+
for (const li of form.querySelectorAll('.ws-history li')) li.hidden = (!!q && !li.dataset.sql.includes(q)) || (!!histS.value && li.dataset.status !== histS.value);
|
|
202
|
+
};
|
|
203
|
+
histQ?.addEventListener('input', filterHistory);
|
|
204
|
+
histQ?.addEventListener('keydown', (e) => { if (e.key === 'Enter') e.preventDefault(); });
|
|
205
|
+
histS?.addEventListener('change', filterHistory);
|
|
206
|
+
form.addEventListener('click', (e) => {
|
|
207
|
+
const again = e.target.closest('[data-ws-run]');
|
|
208
|
+
if (again) {
|
|
209
|
+
const before = ta.value.replace(/\s+$/, '');
|
|
210
|
+
const start = before ? before.length + 2 : 0;
|
|
211
|
+
ta.value = `${before}${before ? '\n\n' : ''}${again.dataset.wsRun};\n`;
|
|
212
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
213
|
+
ta.setSelectionRange(start, start + again.dataset.wsRun.length);
|
|
214
|
+
run('statement');
|
|
215
|
+
}
|
|
216
|
+
const c = e.target.closest('[data-ws-copy-text]');
|
|
217
|
+
if (c) copy(c.dataset.wsCopyText, 'The statement');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ------------------------------------------------------------ (sprint 93) worksheets as tabs, kept in this browser
|
|
221
|
+
const wtabs = form.querySelector('.ws-wtabs');
|
|
222
|
+
const readTabs = () => { try { const v = JSON.parse(store.get('tabs') || 'null'); return v && Array.isArray(v.tabs) ? v : null; } catch { return null; } };
|
|
223
|
+
const book = readTabs() || { active: '', tabs: [] };
|
|
224
|
+
const newKey = () => Math.random().toString(36).slice(2, 10);
|
|
225
|
+
const untitled = () => { let n = 1; while (book.tabs.some((t) => t.name === `Untitled ${n}`)) n++; return `Untitled ${n}`; };
|
|
226
|
+
const src = form.dataset.source;
|
|
227
|
+
const sid = form.dataset.scriptId ? Number(form.dataset.scriptId) : null;
|
|
228
|
+
const sname = form.dataset.scriptName;
|
|
229
|
+
const add = (tab) => { book.tabs.push(tab); if (book.tabs.length > 20) book.tabs.splice(0, book.tabs.length - 20); return tab; };
|
|
230
|
+
let cur = form.dataset.tab ? book.tabs.find((t) => t.key === form.dataset.tab) : null;
|
|
231
|
+
if (src === 'script') {
|
|
232
|
+
cur = cur || book.tabs.find((t) => t.scriptId === sid);
|
|
233
|
+
if (cur && cur.scriptId === sid && cur.text !== cur.saved && !form.dataset.tab) ta.value = cur.text; // unsaved edits of this script stay
|
|
234
|
+
else if (cur) cur.saved = ta.value;
|
|
235
|
+
cur = cur || add({ key: newKey(), name: sname, scriptId: sid, text: ta.value, saved: ta.value });
|
|
236
|
+
cur.scriptId = sid;
|
|
237
|
+
cur.name = sname;
|
|
238
|
+
cur.text = ta.value;
|
|
239
|
+
} else if (src === 'run') {
|
|
240
|
+
cur = cur || add({ key: newKey(), name: sid ? sname : untitled(), scriptId: sid, text: ta.value, saved: sid ? '' : ta.value });
|
|
241
|
+
cur.text = ta.value;
|
|
242
|
+
} else if (src === 'insert') {
|
|
243
|
+
cur = add({ key: newKey(), name: untitled(), scriptId: null, text: ta.value, saved: '' });
|
|
244
|
+
} else {
|
|
245
|
+
cur = book.tabs.find((t) => t.key === book.active) || book.tabs[0];
|
|
246
|
+
if (cur) ta.value = cur.text;
|
|
247
|
+
else cur = add({ key: newKey(), name: untitled(), scriptId: null, text: ta.value, saved: ta.value });
|
|
248
|
+
}
|
|
249
|
+
ta.dispatchEvent(new Event('input', { bubbles: true })); // the code editor redraws
|
|
250
|
+
const dirty = (t) => t.text !== t.saved;
|
|
251
|
+
let persistTimer = 0;
|
|
252
|
+
const persistNow = () => { clearTimeout(persistTimer); cur.text = ta.value; store.set('tabs', JSON.stringify(book)); };
|
|
253
|
+
const persist = () => { clearTimeout(persistTimer); persistTimer = setTimeout(persistNow, 250); };
|
|
254
|
+
window.addEventListener('pagehide', persistNow);
|
|
255
|
+
form.addEventListener('submit', persistNow);
|
|
256
|
+
const drawTabs = () => {
|
|
257
|
+
book.active = cur.key;
|
|
258
|
+
form.elements.ws_tab.value = cur.key;
|
|
259
|
+
form.elements.script_id.value = cur.scriptId ?? '';
|
|
260
|
+
const name = form.elements.ws_name;
|
|
261
|
+
if (document.activeElement !== name) name.value = cur.scriptId ? cur.name : '';
|
|
262
|
+
form.querySelector('.ws-file-name').textContent = `${cur.name}${dirty(cur) ? '*' : ''}`;
|
|
263
|
+
wtabs.replaceChildren(...book.tabs.map((t) => {
|
|
264
|
+
const wrap = document.createElement('div');
|
|
265
|
+
wrap.className = 'ws-wtab';
|
|
266
|
+
const b = document.createElement('button');
|
|
267
|
+
b.type = 'button';
|
|
268
|
+
b.setAttribute('role', 'tab');
|
|
269
|
+
b.setAttribute('aria-selected', String(t === cur));
|
|
270
|
+
b.dataset.wsWtab = t.key;
|
|
271
|
+
b.textContent = `${t.name}${dirty(t) ? '*' : ''}`;
|
|
272
|
+
b.title = t.scriptId ? `Script “${t.name}”${dirty(t) ? ' (unsaved changes)' : ''}` : 'Not saved as a script';
|
|
273
|
+
const x = document.createElement('button');
|
|
274
|
+
x.type = 'button';
|
|
275
|
+
x.className = 'ws-wtab-close';
|
|
276
|
+
x.dataset.wsWclose = t.key;
|
|
277
|
+
x.setAttribute('aria-label', `Close ${t.name}`);
|
|
278
|
+
x.textContent = '×';
|
|
279
|
+
wrap.append(b, x);
|
|
280
|
+
return wrap;
|
|
281
|
+
}), Object.assign(document.createElement('button'), { type: 'button', className: 'ws-wtab-new', title: 'New worksheet', textContent: '+' }));
|
|
282
|
+
persist();
|
|
283
|
+
};
|
|
284
|
+
const switchTo = (t) => {
|
|
285
|
+
cur.text = ta.value;
|
|
286
|
+
cur = t;
|
|
287
|
+
ta.value = t.text;
|
|
288
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
289
|
+
drawTabs();
|
|
290
|
+
ta.focus();
|
|
291
|
+
};
|
|
292
|
+
const newTab = () => switchTo(add({ key: newKey(), name: untitled(), scriptId: null, text: '', saved: '' }));
|
|
293
|
+
const closeTab = (t) => {
|
|
294
|
+
if (dirty(t) && t.text.trim() && !window.confirm(`Close “${t.name}” without saving?`)) return;
|
|
295
|
+
const i = book.tabs.indexOf(t);
|
|
296
|
+
book.tabs.splice(i, 1);
|
|
297
|
+
if (t === cur) {
|
|
298
|
+
cur = { text: '' }; // gone: switchTo keeps nothing of it
|
|
299
|
+
const next = book.tabs[Math.min(i, book.tabs.length - 1)];
|
|
300
|
+
if (next) switchTo(next);
|
|
301
|
+
else newTab();
|
|
302
|
+
} else drawTabs();
|
|
303
|
+
};
|
|
304
|
+
ta.addEventListener('input', () => {
|
|
305
|
+
cur.text = ta.value;
|
|
306
|
+
const label = form.querySelector('.ws-file-name');
|
|
307
|
+
const want = `${cur.name}${dirty(cur) ? '*' : ''}`;
|
|
308
|
+
if (label.textContent !== want) drawTabs();
|
|
309
|
+
else persist();
|
|
310
|
+
});
|
|
311
|
+
wtabs.addEventListener('click', (e) => {
|
|
312
|
+
const b = e.target.closest('[data-ws-wtab]');
|
|
313
|
+
if (b) switchTo(book.tabs.find((t) => t.key === b.dataset.wsWtab));
|
|
314
|
+
const x = e.target.closest('[data-ws-wclose]');
|
|
315
|
+
if (x) closeTab(book.tabs.find((t) => t.key === x.dataset.wsWclose));
|
|
316
|
+
if (e.target.closest('.ws-wtab-new')) newTab();
|
|
317
|
+
});
|
|
318
|
+
drawTabs();
|
|
319
|
+
const fileMenu = form.querySelector('.ws-file');
|
|
320
|
+
const save = () => {
|
|
321
|
+
if (cur.scriptId) { form.requestSubmit(button('save')); return; }
|
|
322
|
+
fileMenu.open = true;
|
|
323
|
+
form.elements.ws_name.focus();
|
|
324
|
+
};
|
|
325
|
+
form.elements.ws_name.addEventListener('keydown', (e) => {
|
|
326
|
+
if (e.key !== 'Enter') return;
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
form.requestSubmit(button(cur.scriptId && form.elements.ws_name.value.trim() !== cur.name ? 'rename' : 'save'));
|
|
329
|
+
});
|
|
330
|
+
form.querySelector('[data-ws-new]').addEventListener('click', (e) => { e.preventDefault(); fileMenu.open = false; newTab(); });
|
|
331
|
+
form.querySelector('[data-ws-open]').addEventListener('click', (e) => { e.preventDefault(); fileMenu.open = false; navTab('files'); });
|
|
332
|
+
// the navigator's Objects and Files
|
|
333
|
+
const navTab = (key) => {
|
|
334
|
+
for (const b of form.querySelectorAll('[data-ws-navtab]')) b.setAttribute('aria-selected', String(b.dataset.wsNavtab === key));
|
|
335
|
+
form.querySelector('#ws-nav-objects').hidden = key !== 'objects';
|
|
336
|
+
form.querySelector('#ws-files').hidden = key !== 'files';
|
|
337
|
+
if (form.classList.contains('ws-nav-hidden')) toggleNav(false);
|
|
338
|
+
};
|
|
339
|
+
form.querySelector('.ws-nav-switch').addEventListener('click', (e) => { const b = e.target.closest('[data-ws-navtab]'); if (b) navTab(b.dataset.wsNavtab); });
|
|
340
|
+
navTab(location.hash === '#ws-files' ? 'files' : 'objects');
|
|
341
|
+
|
|
342
|
+
// ------------------------------------------------------------ (sprint 93) bind and substitution values
|
|
343
|
+
const binds = form.querySelector('[data-ws-binds]');
|
|
344
|
+
if (binds) {
|
|
345
|
+
binds.close();
|
|
346
|
+
binds.showModal();
|
|
347
|
+
binds.querySelector('input:not([type=hidden])')?.select();
|
|
348
|
+
const drop = () => binds.remove(); // its values must not go along with the next run
|
|
349
|
+
binds.addEventListener('cancel', drop);
|
|
350
|
+
binds.querySelector('[data-ws-binds-cancel]').addEventListener('click', (e) => { e.preventDefault(); binds.close(); drop(); ta.focus(); });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ------------------------------------------------------------ (sprint 93) editing tools
|
|
354
|
+
const replaceRange = (a, b, text, select = true) => {
|
|
355
|
+
ta.focus();
|
|
356
|
+
ta.setRangeText(text, a, b, select ? 'select' : 'end');
|
|
357
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
358
|
+
};
|
|
359
|
+
const kwcase = form.querySelector('[data-ws-kwcase]');
|
|
360
|
+
kwcase.value = store.get('kwcase') === 'upper' ? 'upper' : 'lower';
|
|
361
|
+
kwcase.addEventListener('change', () => store.set('kwcase', kwcase.value));
|
|
362
|
+
const format = async () => {
|
|
363
|
+
const a = ta.selectionStart, b = ta.selectionEnd;
|
|
364
|
+
const whole = a === b;
|
|
365
|
+
const sql = whole ? ta.value : ta.value.slice(a, b);
|
|
366
|
+
if (!sql.trim()) return;
|
|
367
|
+
try {
|
|
368
|
+
const res = await fetch('/builder/sql/worksheet/format', { method: 'POST', credentials: 'same-origin', body: new URLSearchParams({ __csrf: form.elements.__csrf.value, sql, case: kwcase.value }) });
|
|
369
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
370
|
+
const out = (await res.json()).sql;
|
|
371
|
+
if (whole) replaceRange(0, ta.value.length, out, false);
|
|
372
|
+
else replaceRange(a, b, out);
|
|
373
|
+
announce('Formatted.');
|
|
374
|
+
} catch {
|
|
375
|
+
announce('Formatting failed.');
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
const convertCase = () => {
|
|
379
|
+
let a = ta.selectionStart, b = ta.selectionEnd;
|
|
380
|
+
if (a === b) {
|
|
381
|
+
while (a > 0 && /\w/.test(ta.value[a - 1])) a--;
|
|
382
|
+
while (b < ta.value.length && /\w/.test(ta.value[b])) b++;
|
|
383
|
+
}
|
|
384
|
+
const t = ta.value.slice(a, b);
|
|
385
|
+
if (!t) return;
|
|
386
|
+
const next = t === t.toUpperCase() ? t.toLowerCase() : t === t.toLowerCase() ? t.replace(/\b\w/g, (c) => c.toUpperCase()) : t.toUpperCase();
|
|
387
|
+
replaceRange(a, b, next);
|
|
388
|
+
};
|
|
389
|
+
const commentLines = () => {
|
|
390
|
+
const v = ta.value;
|
|
391
|
+
const a = v.lastIndexOf('\n', ta.selectionStart - 1) + 1;
|
|
392
|
+
let b = v.indexOf('\n', Math.max(ta.selectionEnd - (ta.selectionEnd > ta.selectionStart && v[ta.selectionEnd - 1] === '\n' ? 1 : 0), a));
|
|
393
|
+
if (b === -1) b = v.length;
|
|
394
|
+
const lines = v.slice(a, b).split('\n');
|
|
395
|
+
const all = lines.every((l) => !l.trim() || /^\s*--/.test(l));
|
|
396
|
+
replaceRange(a, b, lines.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)-- ?/, '$1') : `-- ${l}`)).join('\n'));
|
|
397
|
+
};
|
|
398
|
+
// find and replace
|
|
399
|
+
const bar = form.querySelector('.ws-findbar');
|
|
400
|
+
const fq = bar.querySelector('[data-ws-find-q]'), fr = bar.querySelector('[data-ws-find-r]');
|
|
401
|
+
const fre = bar.querySelector('[data-ws-find-re]'), fcs = bar.querySelector('[data-ws-find-cs]');
|
|
402
|
+
const fmsg = bar.querySelector('[data-ws-find-msg]');
|
|
403
|
+
const pattern = () => {
|
|
404
|
+
if (!fq.value) return null;
|
|
405
|
+
try {
|
|
406
|
+
return new RegExp(fre.checked ? fq.value : fq.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), `g${fcs.checked ? '' : 'i'}`);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
fmsg.textContent = `Not a valid pattern: ${err.message}`;
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const reveal = (a, b) => {
|
|
413
|
+
const back = document.activeElement;
|
|
414
|
+
ta.focus();
|
|
415
|
+
ta.setSelectionRange(a, b);
|
|
416
|
+
if (back && back !== ta && bar.contains(back)) back.focus();
|
|
417
|
+
};
|
|
418
|
+
const findNext = (from = ta.selectionEnd) => {
|
|
419
|
+
const re = pattern();
|
|
420
|
+
if (!re) return false;
|
|
421
|
+
re.lastIndex = from;
|
|
422
|
+
let m = re.exec(ta.value);
|
|
423
|
+
if (!m) { re.lastIndex = 0; m = re.exec(ta.value); }
|
|
424
|
+
if (!m || !m[0]) { fmsg.textContent = 'Not found.'; return false; }
|
|
425
|
+
fmsg.textContent = '';
|
|
426
|
+
reveal(m.index, m.index + m[0].length);
|
|
427
|
+
return true;
|
|
428
|
+
};
|
|
429
|
+
const openFind = (k) => {
|
|
430
|
+
bar.hidden = false;
|
|
431
|
+
const sel = ta.value.slice(ta.selectionStart, ta.selectionEnd);
|
|
432
|
+
if (sel && !sel.includes('\n') && k !== 'g') fq.value = sel;
|
|
433
|
+
(k === 'h' ? fr : k === 'g' ? bar.querySelector('[data-ws-goto]') : fq).focus();
|
|
434
|
+
};
|
|
435
|
+
form.querySelector('[data-ws-find]').addEventListener('click', () => openFind('f'));
|
|
436
|
+
form.querySelector('[data-ws-format]').addEventListener('click', format);
|
|
437
|
+
form.querySelector('[data-ws-case]').addEventListener('click', convertCase);
|
|
438
|
+
bar.addEventListener('keydown', (e) => {
|
|
439
|
+
if (e.key === 'Escape') { e.preventDefault(); bar.hidden = true; ta.focus(); return; }
|
|
440
|
+
if (e.key !== 'Enter') return;
|
|
441
|
+
e.preventDefault();
|
|
442
|
+
if (e.target.matches('[data-ws-goto]')) {
|
|
443
|
+
const n = Math.max(1, Number(e.target.value) || 1);
|
|
444
|
+
const lines = ta.value.split('\n');
|
|
445
|
+
const at = lines.slice(0, n - 1).reduce((x, l) => x + l.length + 1, 0);
|
|
446
|
+
ta.focus();
|
|
447
|
+
ta.setSelectionRange(Math.min(at, ta.value.length), Math.min(at, ta.value.length));
|
|
448
|
+
} else if (e.target === fr) bar.querySelector('[data-ws-find-replace]').click();
|
|
449
|
+
else findNext(e.shiftKey ? 0 : ta.selectionEnd);
|
|
450
|
+
});
|
|
451
|
+
bar.addEventListener('click', (e) => {
|
|
452
|
+
if (e.target.closest('[data-ws-find-next]')) findNext();
|
|
453
|
+
if (e.target.closest('[data-ws-find-close]')) { bar.hidden = true; ta.focus(); }
|
|
454
|
+
if (e.target.closest('[data-ws-find-replace]')) {
|
|
455
|
+
const re = pattern();
|
|
456
|
+
if (!re) return;
|
|
457
|
+
const sel = ta.value.slice(ta.selectionStart, ta.selectionEnd);
|
|
458
|
+
const one = new RegExp(`^(?:${re.source})$`, re.flags.replace('g', ''));
|
|
459
|
+
if (sel && one.test(sel)) replaceRange(ta.selectionStart, ta.selectionEnd, sel.replace(one, fr.value), false);
|
|
460
|
+
findNext();
|
|
461
|
+
}
|
|
462
|
+
if (e.target.closest('[data-ws-find-all]')) {
|
|
463
|
+
const re = pattern();
|
|
464
|
+
if (!re) return;
|
|
465
|
+
const n = (ta.value.match(re) || []).length;
|
|
466
|
+
if (n) replaceRange(0, ta.value.length, ta.value.replace(re, fr.value), false);
|
|
467
|
+
fmsg.textContent = `${n} replaced.`;
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ------------------------------------------------------------ navigator
|
|
472
|
+
const navBody = document.getElementById('ws-nav-body');
|
|
473
|
+
const filter = form.querySelector('[data-ws-filter]');
|
|
474
|
+
const applyFilter = () => {
|
|
475
|
+
const q = filter.value.trim().toLowerCase();
|
|
476
|
+
for (const li of navBody.querySelectorAll('.ws-obj')) li.hidden = !!q && !li.dataset.name.includes(q);
|
|
477
|
+
};
|
|
478
|
+
filter.addEventListener('input', applyFilter);
|
|
479
|
+
filter.addEventListener('keydown', (e) => { if (e.key === 'Enter') e.preventDefault(); });
|
|
480
|
+
for (const sel of form.querySelectorAll('select[data-ws-nav]')) {
|
|
481
|
+
sel.addEventListener('change', async () => {
|
|
482
|
+
const q = new URLSearchParams({ schema: form.elements.nav_schema.value, type: form.elements.nav_type.value });
|
|
483
|
+
navBody.setAttribute('aria-busy', 'true');
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(`/builder/sql/worksheet/navigator?${q}`, { credentials: 'same-origin' });
|
|
486
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
487
|
+
navBody.innerHTML = await res.text(); // server-rendered, escaped HTML of the same origin
|
|
488
|
+
for (const el of navBody.querySelectorAll('[hidden]')) el.hidden = false;
|
|
489
|
+
applyFilter();
|
|
490
|
+
} catch {
|
|
491
|
+
form.requestSubmit(button('nav'));
|
|
492
|
+
} finally {
|
|
493
|
+
navBody.removeAttribute('aria-busy');
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ------------------------------------------------------------ panes
|
|
499
|
+
const toggleNav = (hide = !form.classList.contains('ws-nav-hidden')) => {
|
|
500
|
+
form.classList.toggle('ws-nav-hidden', hide);
|
|
501
|
+
store.set('nav', hide ? 'hidden' : '');
|
|
502
|
+
};
|
|
503
|
+
if (store.get('nav') === 'hidden') toggleNav(true);
|
|
504
|
+
const fullscreen = (on) => {
|
|
505
|
+
document.body.classList.toggle('ws-fullscreen', on);
|
|
506
|
+
form.querySelector('[data-ws-fullscreen]')?.setAttribute('aria-pressed', String(on));
|
|
507
|
+
if (on) ta.focus();
|
|
508
|
+
};
|
|
509
|
+
const size = (key, px) => {
|
|
510
|
+
const v = key === 'nav' ? Math.max(160, Math.min(px, window.innerWidth * 0.5)) : Math.max(80, Math.min(px, window.innerHeight * 0.75));
|
|
511
|
+
form.style.setProperty(key === 'nav' ? '--ws-nav' : '--ws-out', `${Math.round(v)}px`);
|
|
512
|
+
store.set(key, String(Math.round(v)));
|
|
513
|
+
return v;
|
|
514
|
+
};
|
|
515
|
+
for (const key of ['nav', 'out']) if (Number(store.get(key)) > 0) size(key, Number(store.get(key)));
|
|
516
|
+
for (const sp of form.querySelectorAll('[data-ws-split]')) {
|
|
517
|
+
const key = sp.dataset.wsSplit;
|
|
518
|
+
const current = () => (key === 'nav' ? form.querySelector('.ws-nav').getBoundingClientRect().width : out.getBoundingClientRect().height);
|
|
519
|
+
sp.addEventListener('pointerdown', (e) => {
|
|
520
|
+
e.preventDefault();
|
|
521
|
+
sp.setPointerCapture(e.pointerId);
|
|
522
|
+
const start = key === 'nav' ? e.clientX : e.clientY;
|
|
523
|
+
const from = current();
|
|
524
|
+
const move = (ev) => size(key, key === 'nav' ? from + ev.clientX - start : from - (ev.clientY - start));
|
|
525
|
+
const up = () => { sp.removeEventListener('pointermove', move); sp.removeEventListener('pointerup', up); };
|
|
526
|
+
sp.addEventListener('pointermove', move);
|
|
527
|
+
sp.addEventListener('pointerup', up);
|
|
528
|
+
});
|
|
529
|
+
sp.addEventListener('keydown', (e) => {
|
|
530
|
+
const step = e.shiftKey ? 64 : 16;
|
|
531
|
+
const d = key === 'nav' ? { ArrowLeft: -step, ArrowRight: step }[e.key] : { ArrowUp: step, ArrowDown: -step }[e.key];
|
|
532
|
+
if (!d) return;
|
|
533
|
+
e.preventDefault();
|
|
534
|
+
size(key, current() + d);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
})();
|
|
538
|
+
|
|
539
|
+
// (sprint 94) An object's ⋯ menu, in the worksheet's navigator and the Object Browser's tree: right-click an object
|
|
540
|
+
// to open it; Open shows the Object Browser's detail in a panel at the side (its tabs and pages load in the panel).
|
|
541
|
+
(() => {
|
|
542
|
+
'use strict';
|
|
543
|
+
document.addEventListener('contextmenu', (e) => {
|
|
544
|
+
const item = e.target.closest?.('.ws-obj, .ob-item');
|
|
545
|
+
const menu = item?.querySelector(':scope > .obj-menu');
|
|
546
|
+
if (!menu || e.shiftKey) return; // Shift+right-click keeps the browser's own menu
|
|
547
|
+
e.preventDefault();
|
|
548
|
+
for (const d of document.querySelectorAll('.obj-menu[open]')) if (d !== menu) d.open = false;
|
|
549
|
+
menu.open = true;
|
|
550
|
+
menu.querySelector('a')?.focus();
|
|
551
|
+
});
|
|
552
|
+
let slide = null;
|
|
553
|
+
const panel = () => {
|
|
554
|
+
if (slide) return slide;
|
|
555
|
+
slide = document.createElement('dialog');
|
|
556
|
+
slide.className = 'obj-slide';
|
|
557
|
+
slide.setAttribute('aria-labelledby', 'obj-slide-title');
|
|
558
|
+
const head = document.createElement('div');
|
|
559
|
+
head.className = 'obj-slide-head';
|
|
560
|
+
const title = Object.assign(document.createElement('strong'), { id: 'obj-slide-title' });
|
|
561
|
+
const full = Object.assign(document.createElement('a'), { className: 'btn btn-sm', textContent: 'Open in the Object Browser' });
|
|
562
|
+
const close = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: 'Close' });
|
|
563
|
+
close.addEventListener('click', () => slide.close());
|
|
564
|
+
const tools = document.createElement('div');
|
|
565
|
+
tools.append(full, ' ', close);
|
|
566
|
+
head.append(title, tools);
|
|
567
|
+
const body = Object.assign(document.createElement('div'), { className: 'obj-slide-body' });
|
|
568
|
+
slide.append(head, body);
|
|
569
|
+
document.body.append(slide);
|
|
570
|
+
body.addEventListener('click', (e) => {
|
|
571
|
+
const a = e.target.closest('a[href^="/builder/sql/objects/panel"]');
|
|
572
|
+
if (!a || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
573
|
+
e.preventDefault();
|
|
574
|
+
load(a.getAttribute('href'));
|
|
575
|
+
});
|
|
576
|
+
slide.addEventListener('click', (e) => { if (e.target === slide) slide.close(); }); // the backdrop
|
|
577
|
+
return slide;
|
|
578
|
+
};
|
|
579
|
+
const load = async (url) => {
|
|
580
|
+
const d = panel();
|
|
581
|
+
const body = d.querySelector('.obj-slide-body');
|
|
582
|
+
body.setAttribute('aria-busy', 'true');
|
|
583
|
+
try {
|
|
584
|
+
const res = await fetch(url, { credentials: 'same-origin' });
|
|
585
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
586
|
+
body.innerHTML = await res.text(); // server-rendered, escaped HTML of the same origin
|
|
587
|
+
const root = body.querySelector('.obj-panel');
|
|
588
|
+
d.querySelector('#obj-slide-title').textContent = root?.dataset.label ?? '';
|
|
589
|
+
d.querySelector('.obj-slide-head a').href = url.replace('/sql/objects/panel', '/sql/objects');
|
|
590
|
+
} catch {
|
|
591
|
+
body.textContent = 'The object could not be opened.';
|
|
592
|
+
} finally {
|
|
593
|
+
body.removeAttribute('aria-busy');
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
document.addEventListener('click', (e) => {
|
|
597
|
+
const a = e.target.closest?.('[data-obj-open]');
|
|
598
|
+
if (!a || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
|
599
|
+
e.preventDefault();
|
|
600
|
+
a.closest('details')?.removeAttribute('open');
|
|
601
|
+
const d = panel();
|
|
602
|
+
if (!d.open) d.showModal();
|
|
603
|
+
load(a.dataset.objOpen);
|
|
604
|
+
});
|
|
605
|
+
})();
|
|
606
|
+
|
|
607
|
+
// (sprint 95) Table Properties: sections as tabs, rows added, moved and removed without a round trip.
|
|
608
|
+
(() => {
|
|
609
|
+
'use strict';
|
|
610
|
+
const form = document.querySelector('form.te');
|
|
611
|
+
if (!form) return;
|
|
612
|
+
for (const el of form.querySelectorAll('[hidden]')) el.hidden = false;
|
|
613
|
+
const show = (key) => {
|
|
614
|
+
for (const b of form.querySelectorAll('[data-te-show]')) b.setAttribute('aria-selected', String(b.dataset.teShow === key));
|
|
615
|
+
for (const sct of form.querySelectorAll('[data-te-section]')) sct.hidden = sct.dataset.teSection !== key;
|
|
616
|
+
};
|
|
617
|
+
show(form.dataset.teTab || 'columns');
|
|
618
|
+
form.querySelector('.te-tabs').addEventListener('click', (e) => { const b = e.target.closest('[data-te-show]'); if (b) show(b.dataset.teShow); });
|
|
619
|
+
// a preview opens on the DDL tab: Enter in a field previews instead of applying
|
|
620
|
+
form.addEventListener('keydown', (e) => {
|
|
621
|
+
if (e.key === 'Enter' && e.target.matches('input:not([type=checkbox])')) { e.preventDefault(); form.requestSubmit(form.querySelector('button[value=preview]')); }
|
|
622
|
+
});
|
|
623
|
+
// spare rows: one shows, the others come with Add
|
|
624
|
+
for (const grid of form.querySelectorAll('[data-te-grid]')) {
|
|
625
|
+
// an empty list keeps one empty row to fill in; the others wait for Add
|
|
626
|
+
const real = grid.querySelectorAll('tr.te-row:not(.te-spare)').length;
|
|
627
|
+
[...grid.querySelectorAll('tr.te-spare')].forEach((r, i) => { r.hidden = real > 0 || i > 0; });
|
|
628
|
+
if (grid.dataset.teGrid !== 'col' && !grid.parentElement.parentElement.querySelector(`[data-te-add="${grid.dataset.teGrid}"]`)) {
|
|
629
|
+
const b = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm', textContent: '+ Add' });
|
|
630
|
+
b.dataset.teAdd = grid.dataset.teGrid;
|
|
631
|
+
grid.closest('.table-wrap').after(b);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const renumber = (row, prefix, i) => {
|
|
635
|
+
for (const el of row.querySelectorAll('[name]')) el.name = el.name.replace(new RegExp(`^${prefix}\\.\\d+\\.`), `${prefix}.${i}.`);
|
|
636
|
+
for (const el of row.querySelectorAll('[id]')) el.id = `${el.id.replace(/-n\d+$/, '')}-n${i}`;
|
|
637
|
+
for (const el of row.querySelectorAll('label[for]')) el.htmlFor = `${el.htmlFor.replace(/-n\d+$/, '')}-n${i}`;
|
|
638
|
+
};
|
|
639
|
+
form.addEventListener('click', (e) => {
|
|
640
|
+
const add = e.target.closest('[data-te-add]');
|
|
641
|
+
if (add) {
|
|
642
|
+
const grid = form.querySelector(`[data-te-grid="${add.dataset.teAdd}"]`);
|
|
643
|
+
const hidden = grid.querySelector('tr.te-spare[hidden]');
|
|
644
|
+
if (hidden) { hidden.hidden = false; hidden.querySelector('input:not([type=hidden])')?.focus(); return; }
|
|
645
|
+
const rows = grid.tBodies[0].rows;
|
|
646
|
+
const copy = rows[rows.length - 1].cloneNode(true);
|
|
647
|
+
for (const el of copy.querySelectorAll('input')) { if (el.type === 'checkbox') el.checked = false; else el.value = ''; }
|
|
648
|
+
renumber(copy, add.dataset.teAdd, rows.length);
|
|
649
|
+
grid.tBodies[0].append(copy);
|
|
650
|
+
copy.querySelector('input:not([type=hidden])')?.focus();
|
|
651
|
+
}
|
|
652
|
+
const up = e.target.closest('[data-te-up], [data-te-down]');
|
|
653
|
+
if (up) {
|
|
654
|
+
const row = up.closest('tr');
|
|
655
|
+
const other = up.matches('[data-te-up]') ? row.previousElementSibling : row.nextElementSibling;
|
|
656
|
+
if (!other) return;
|
|
657
|
+
if (up.matches('[data-te-up]')) other.before(row); else other.after(row);
|
|
658
|
+
// the order of the fields is the order of the columns: number the rows again
|
|
659
|
+
[...row.parentElement.rows].forEach((r, i) => renumber(r, 'col', i));
|
|
660
|
+
up.focus();
|
|
661
|
+
}
|
|
662
|
+
const drop = e.target.closest('.te-drop input');
|
|
663
|
+
if (drop) drop.closest('tr').classList.toggle('te-removed', drop.checked);
|
|
664
|
+
});
|
|
665
|
+
})();
|