tablewalk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query history: the last 50 queries that ran, per connection.
|
|
3
|
+
*
|
|
4
|
+
* Two things shape this file.
|
|
5
|
+
*
|
|
6
|
+
* The first is that history is a convenience, never a dependency. Every
|
|
7
|
+
* `localStorage` call is wrapped, because the API throws rather than returning
|
|
8
|
+
* null in three ordinary situations — Safari's private mode, a browser set to
|
|
9
|
+
* block site data, and a full quota — and a database browser that fails to
|
|
10
|
+
* paint because it could not remember what you typed is worse than one that
|
|
11
|
+
* never offered to. When storage refuses, the list falls back to memory and
|
|
12
|
+
* works until the tab is closed.
|
|
13
|
+
*
|
|
14
|
+
* The second is that history is per connection. The stored value is keyed by
|
|
15
|
+
* `state.activeConnection`, so switching from staging to production does not
|
|
16
|
+
* offer you queries against tables the new database may not even have.
|
|
17
|
+
*/
|
|
18
|
+
import { $, el, loadJson, saveJson, state, toast } from './core.js';
|
|
19
|
+
|
|
20
|
+
const KEY = 'tablewalk.history.v1';
|
|
21
|
+
|
|
22
|
+
/* Enough to cover a session's worth of exploration, small enough that the
|
|
23
|
+
whole record stays well inside a quota even with long queries. */
|
|
24
|
+
const MAX_ENTRIES = 50;
|
|
25
|
+
|
|
26
|
+
/** The whole record: `{ [connectionId]: [{ q, at }] }`. Never throws. */
|
|
27
|
+
function readAll() {
|
|
28
|
+
const parsed = loadJson(KEY, {});
|
|
29
|
+
// Anything could be under this key — an older shape, or another tool's.
|
|
30
|
+
// Refusing to trust it is cheaper than defending every read site.
|
|
31
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* The in-memory mirror, and the reason a failed write is not a failed feature:
|
|
35
|
+
the dropdown reads from here, so history keeps working for the session even
|
|
36
|
+
when nothing can be persisted. */
|
|
37
|
+
const cache = new Map();
|
|
38
|
+
|
|
39
|
+
const scope = () => state.activeConnection ?? 'default';
|
|
40
|
+
|
|
41
|
+
/** The history for the connection in use, newest first. */
|
|
42
|
+
export function historyFor(connection = scope()) {
|
|
43
|
+
if (!cache.has(connection)) {
|
|
44
|
+
const stored = readAll()[connection];
|
|
45
|
+
cache.set(connection, Array.isArray(stored) ? stored.filter((e) => e && typeof e.q === 'string') : []);
|
|
46
|
+
}
|
|
47
|
+
return cache.get(connection);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remember a query that ran.
|
|
52
|
+
*
|
|
53
|
+
* Re-running something moves it to the top instead of adding a second copy:
|
|
54
|
+
* the list is meant to answer "what have I been looking at", and twelve
|
|
55
|
+
* identical rows of `customer` push the query you actually want off the end.
|
|
56
|
+
*/
|
|
57
|
+
export function recordQuery(text) {
|
|
58
|
+
const q = String(text ?? '').trim();
|
|
59
|
+
if (!q) return;
|
|
60
|
+
const connection = scope();
|
|
61
|
+
const kept = historyFor(connection).filter((entry) => entry.q !== q);
|
|
62
|
+
kept.unshift({ q, at: Date.now() });
|
|
63
|
+
kept.length = Math.min(kept.length, MAX_ENTRIES);
|
|
64
|
+
cache.set(connection, kept);
|
|
65
|
+
|
|
66
|
+
const all = readAll();
|
|
67
|
+
all[connection] = kept;
|
|
68
|
+
// Best effort: saveJson swallows a full or unavailable localStorage, and
|
|
69
|
+
// the in-memory copy above keeps the list working for the session either way.
|
|
70
|
+
saveJson(KEY, all);
|
|
71
|
+
cursor = -1;
|
|
72
|
+
draft = null;
|
|
73
|
+
if (panel && !panel.hidden) fillPanel();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clearHistory() {
|
|
77
|
+
const connection = scope();
|
|
78
|
+
cache.set(connection, []);
|
|
79
|
+
const all = readAll();
|
|
80
|
+
delete all[connection];
|
|
81
|
+
saveJson(KEY, all);
|
|
82
|
+
fillPanel();
|
|
83
|
+
toast('Query history cleared.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ---------- the control ---------- */
|
|
87
|
+
|
|
88
|
+
let panel = null;
|
|
89
|
+
let toggle = null;
|
|
90
|
+
let installed = false;
|
|
91
|
+
|
|
92
|
+
/* Where in the list the ↑/↓ walk currently is: -1 means "on what you typed",
|
|
93
|
+
and `draft` holds that text so walking back down returns it rather than
|
|
94
|
+
leaving the last history entry in the box. */
|
|
95
|
+
let cursor = -1;
|
|
96
|
+
let draft = null;
|
|
97
|
+
|
|
98
|
+
function ago(at) {
|
|
99
|
+
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
100
|
+
if (seconds < 60) return 'just now';
|
|
101
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
|
|
102
|
+
if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`;
|
|
103
|
+
return `${Math.round(seconds / 86400)}d ago`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function fillPanel() {
|
|
107
|
+
const entries = historyFor();
|
|
108
|
+
panel.replaceChildren();
|
|
109
|
+
if (!entries.length) {
|
|
110
|
+
panel.append(el('p', {
|
|
111
|
+
class: 'history-empty',
|
|
112
|
+
text: 'Nothing yet. Queries appear here once they run.',
|
|
113
|
+
}));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const list = el('ul', { class: 'history-list' });
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
list.append(el('li', {}, el('button', {
|
|
119
|
+
type: 'button',
|
|
120
|
+
class: 'history-item',
|
|
121
|
+
onclick: () => runEntry(entry.q),
|
|
122
|
+
}, [
|
|
123
|
+
el('span', { class: 'history-q', text: entry.q }),
|
|
124
|
+
el('span', { class: 'history-age', text: ago(entry.at ?? Date.now()) }),
|
|
125
|
+
])));
|
|
126
|
+
}
|
|
127
|
+
panel.append(list, el('button', {
|
|
128
|
+
type: 'button',
|
|
129
|
+
class: 'history-clear',
|
|
130
|
+
text: `Clear ${entries.length} ${entries.length === 1 ? 'query' : 'queries'}`,
|
|
131
|
+
onclick: clearHistory,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function runEntry(q) {
|
|
136
|
+
const input = $('query');
|
|
137
|
+
input.value = q;
|
|
138
|
+
setOpen(false);
|
|
139
|
+
cursor = -1;
|
|
140
|
+
draft = null;
|
|
141
|
+
/* `requestSubmit` rather than `submit`: it fires the form's submit event, so
|
|
142
|
+
the one handler in app.js decides what a query means. `submit()` would
|
|
143
|
+
skip it and reload the page. */
|
|
144
|
+
$('querybar').requestSubmit();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function setOpen(open) {
|
|
148
|
+
panel.hidden = !open;
|
|
149
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
150
|
+
if (open) fillPanel();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Step through the list in the input itself, without opening the panel. */
|
|
154
|
+
function step(direction) {
|
|
155
|
+
const entries = historyFor();
|
|
156
|
+
if (!entries.length) return;
|
|
157
|
+
const input = $('query');
|
|
158
|
+
if (cursor === -1) draft = input.value;
|
|
159
|
+
const next = Math.min(Math.max(cursor + direction, -1), entries.length - 1);
|
|
160
|
+
cursor = next;
|
|
161
|
+
input.value = next === -1 ? (draft ?? '') : entries[next].q;
|
|
162
|
+
// Caret to the end, or the browser leaves it where it was and the next
|
|
163
|
+
// keystroke lands in the middle of the recalled query.
|
|
164
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Attached on import rather than from a boot function.
|
|
169
|
+
*
|
|
170
|
+
* app.js wires the chrome, and this feature deliberately adds no line to it:
|
|
171
|
+
* importing the module is enough to get the control, which keeps the query bar
|
|
172
|
+
* working the same whether or not history is compiled in.
|
|
173
|
+
*/
|
|
174
|
+
export function initHistory() {
|
|
175
|
+
if (installed) return;
|
|
176
|
+
const bar = $('querybar');
|
|
177
|
+
const input = $('query');
|
|
178
|
+
if (!bar || !input) return;
|
|
179
|
+
installed = true;
|
|
180
|
+
|
|
181
|
+
/* No button in the header any more.
|
|
182
|
+
|
|
183
|
+
Recent queries are reached two better ways: Ctrl/Cmd+↑ in the query bar,
|
|
184
|
+
which is where your hands already are, and ⌘K, which searches them
|
|
185
|
+
alongside everything else. A permanent button for a list you open once a
|
|
186
|
+
session was the most expensive way to offer it. */
|
|
187
|
+
toggle = el('button', { type: 'button', class: 'ghost history-toggle', hidden: true });
|
|
188
|
+
panel = el('div', { class: 'history-panel', hidden: true });
|
|
189
|
+
bar.append(panel);
|
|
190
|
+
|
|
191
|
+
input.addEventListener('keydown', (e) => {
|
|
192
|
+
// Ctrl/Cmd rather than a bare arrow: the query bar is a text field, and
|
|
193
|
+
// stealing plain ↑/↓ would break moving the caret in a long query.
|
|
194
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
step(e.key === 'ArrowUp' ? 1 : -1);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (e.key === 'Escape' && !panel.hidden) {
|
|
200
|
+
e.stopPropagation();
|
|
201
|
+
setOpen(false);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Typing means you have left the recalled entry and are writing your own.
|
|
206
|
+
input.addEventListener('input', () => {
|
|
207
|
+
cursor = -1;
|
|
208
|
+
draft = null;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/* Closed by a click anywhere else, which is what a menu is expected to do —
|
|
212
|
+
on pointerdown so it happens before the click lands on whatever is behind. */
|
|
213
|
+
document.addEventListener('pointerdown', (e) => {
|
|
214
|
+
if (!panel.hidden && !panel.contains(e.target) && e.target !== toggle) setOpen(false);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (document.readyState === 'loading') {
|
|
219
|
+
document.addEventListener('DOMContentLoaded', initHistory, { once: true });
|
|
220
|
+
} else {
|
|
221
|
+
initHistory();
|
|
222
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>tablewalk</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
<link rel="stylesheet" href="/features.css" />
|
|
9
|
+
<link rel="stylesheet" href="/composer.css" />
|
|
10
|
+
<link rel="stylesheet" href="/sql.css" />
|
|
11
|
+
<link rel="stylesheet" href="/help.css" />
|
|
12
|
+
<link rel="stylesheet" href="/page.css" />
|
|
13
|
+
<link
|
|
14
|
+
rel="icon"
|
|
15
|
+
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🔗</text></svg>"
|
|
16
|
+
/>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<header class="topbar">
|
|
20
|
+
<button type="button" class="brand" id="home-button" title="Back to the schema (h)">
|
|
21
|
+
<span class="brand-mark"></span>
|
|
22
|
+
<span class="brand-name">tablewalk</span>
|
|
23
|
+
</button>
|
|
24
|
+
<!-- The name of the connection is also the way in to managing them.
|
|
25
|
+
The sidebar has a gear beside the picker, but the sidebar collapses,
|
|
26
|
+
and "which database am I on" is the one question the top bar answers
|
|
27
|
+
at all times. Clicking the answer is the obvious gesture. -->
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="brand-db"
|
|
31
|
+
id="db-label"
|
|
32
|
+
title="Connections…"
|
|
33
|
+
aria-label="Connections"
|
|
34
|
+
></button>
|
|
35
|
+
<!-- Which database, and the way to a different one, in the chrome that
|
|
36
|
+
is always on screen. It lived in the sidebar, which collapses, and
|
|
37
|
+
which is otherwise entirely about the tables *within* one database
|
|
38
|
+
— the wrong altitude for the control that chooses between them. -->
|
|
39
|
+
<div class="conn-picker" id="conn-picker"></div>
|
|
40
|
+
<form class="querybar" id="querybar" autocomplete="off">
|
|
41
|
+
<input
|
|
42
|
+
id="query"
|
|
43
|
+
type="text"
|
|
44
|
+
spellcheck="false"
|
|
45
|
+
placeholder="customer active = true sort name limit 20"
|
|
46
|
+
aria-label="Query"
|
|
47
|
+
/>
|
|
48
|
+
<button type="submit">Run</button>
|
|
49
|
+
</form>
|
|
50
|
+
<div class="topbar-actions">
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
id="sql-toggle"
|
|
54
|
+
class="icon-button"
|
|
55
|
+
aria-pressed="false"
|
|
56
|
+
title="Show the SQL under the query bar"
|
|
57
|
+
aria-label="Show the SQL"
|
|
58
|
+
>
|
|
59
|
+
SQL
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
id="search-button"
|
|
64
|
+
class="icon-button"
|
|
65
|
+
title="Go to a table, view or database (⌘K)"
|
|
66
|
+
aria-label="Search"
|
|
67
|
+
>
|
|
68
|
+
<span aria-hidden="true">⌘K</span>
|
|
69
|
+
</button>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
id="more-button"
|
|
73
|
+
class="icon-button"
|
|
74
|
+
title="More"
|
|
75
|
+
aria-label="More"
|
|
76
|
+
aria-haspopup="menu"
|
|
77
|
+
aria-expanded="false"
|
|
78
|
+
>
|
|
79
|
+
<span aria-hidden="true">···</span>
|
|
80
|
+
</button>
|
|
81
|
+
<button type="button" id="write-toggle" class="mode" aria-pressed="false">Read only</button>
|
|
82
|
+
</div>
|
|
83
|
+
</header>
|
|
84
|
+
|
|
85
|
+
<div class="explain-pane" id="explain-pane" hidden>
|
|
86
|
+
<pre class="explain" id="explain"></pre>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
id="explain-edit"
|
|
90
|
+
class="explain-edit"
|
|
91
|
+
title="Open this statement in the SQL editor"
|
|
92
|
+
aria-label="Edit this statement and run it"
|
|
93
|
+
>Edit</button>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="errors" id="errors" hidden></div>
|
|
96
|
+
<div class="toast" id="toast" role="status" hidden></div>
|
|
97
|
+
|
|
98
|
+
<main class="layout">
|
|
99
|
+
<nav class="sidebar" aria-label="Tables">
|
|
100
|
+
<input id="table-filter" type="search" placeholder="Filter tables…" aria-label="Filter tables" />
|
|
101
|
+
<ul id="table-list"></ul>
|
|
102
|
+
</nav>
|
|
103
|
+
|
|
104
|
+
<section class="content" id="content">
|
|
105
|
+
<div class="empty" id="empty">
|
|
106
|
+
<h1>Walk the database, don't list it.</h1>
|
|
107
|
+
<p>
|
|
108
|
+
Pick a table on the left, or type a query above. Click any row to open it, and
|
|
109
|
+
tablewalk will show you every row elsewhere that points at it.
|
|
110
|
+
</p>
|
|
111
|
+
</div>
|
|
112
|
+
</section>
|
|
113
|
+
</main>
|
|
114
|
+
</body>
|
|
115
|
+
<script type="module" src="/app.js"></script>
|
|
116
|
+
</html>
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding a row, in a form the schema wrote.
|
|
3
|
+
*
|
|
4
|
+
* Nobody declares a form for this: the columns, their types, what is
|
|
5
|
+
* required and what the database will fill are all in the catalog already.
|
|
6
|
+
* Required is NOT NULL with no default and no auto-assigned key; everything
|
|
7
|
+
* else is optional, and an empty box means "leave it to the database" — its
|
|
8
|
+
* defaults are part of the schema's design and pre-filling them here would
|
|
9
|
+
* turn every default into a value somebody appears to have chosen.
|
|
10
|
+
*
|
|
11
|
+
* What this form deliberately is not: a validator. It marks what the
|
|
12
|
+
* catalog says and sends what was typed; a UNIQUE violation or a bad
|
|
13
|
+
* reference comes back in the database's own words, which name the
|
|
14
|
+
* constraint better than a paraphrase would.
|
|
15
|
+
*/
|
|
16
|
+
import { api, columnKind, el, go, rowLabel, rowView, state, toast } from './core.js';
|
|
17
|
+
import { canDelete } from './delete.js';
|
|
18
|
+
|
|
19
|
+
/** The same gate every write affordance uses: writable, write mode, a table. */
|
|
20
|
+
export const canInsert = canDelete;
|
|
21
|
+
|
|
22
|
+
export function newRowButton(table) {
|
|
23
|
+
if (!canInsert(table)) return null;
|
|
24
|
+
return el('button', {
|
|
25
|
+
type: 'button',
|
|
26
|
+
class: 'ghost table-new-row',
|
|
27
|
+
title: `Add a ${table.name} row`,
|
|
28
|
+
text: '+ New row',
|
|
29
|
+
onclick: () => openInsert(table),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single INTEGER primary key is the database's to assign. */
|
|
34
|
+
function autoKey(table) {
|
|
35
|
+
const pk = table.columns.filter((c) => c.primaryKey);
|
|
36
|
+
return pk.length === 1 && /int/i.test(pk[0].type) ? pk[0].name : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function openInsert(table) {
|
|
40
|
+
const assigned = autoKey(table);
|
|
41
|
+
const inputs = new Map();
|
|
42
|
+
const problem = el('p', { class: 'insert-problem', hidden: true });
|
|
43
|
+
|
|
44
|
+
const field = (column) => {
|
|
45
|
+
const required = !column.nullable && column.default === undefined && column.name !== assigned;
|
|
46
|
+
const hint = column.name === assigned
|
|
47
|
+
? 'assigned by the database'
|
|
48
|
+
: column.default !== undefined
|
|
49
|
+
? `default: ${column.default}`
|
|
50
|
+
: column.references
|
|
51
|
+
? `→ ${column.references.table}`
|
|
52
|
+
: '';
|
|
53
|
+
|
|
54
|
+
/* A select for booleans — three states, none of them typed — and a text
|
|
55
|
+
box for everything else. Numbers and dates arrive as text and are
|
|
56
|
+
coerced against the declared type on the server, exactly as edits are. */
|
|
57
|
+
let input;
|
|
58
|
+
if (column.allowed?.length) {
|
|
59
|
+
/* The database already said what this column may hold, so typing it is
|
|
60
|
+
a chance to get it wrong for nothing. Still a free-text-shaped
|
|
61
|
+
"leave it" option, because an empty box means "the database
|
|
62
|
+
decides" here as everywhere else on this form. */
|
|
63
|
+
input = el('select', { class: 'insert-input', 'aria-label': `${column.name} (one of ${column.allowed.join(', ')})` }, [
|
|
64
|
+
el('option', { value: '', text: hint || '—' }),
|
|
65
|
+
...column.allowed.map((value) => el('option', { value, text: value })),
|
|
66
|
+
]);
|
|
67
|
+
} else if (columnKind(column.type) === 'boolean') {
|
|
68
|
+
input = el('select', { class: 'insert-input', 'aria-label': `${column.name} (${column.type})` }, [
|
|
69
|
+
el('option', { value: '', text: hint || '—' }),
|
|
70
|
+
el('option', { value: '1', text: 'yes' }),
|
|
71
|
+
el('option', { value: '0', text: 'no' }),
|
|
72
|
+
]);
|
|
73
|
+
} else {
|
|
74
|
+
input = el('input', {
|
|
75
|
+
type: 'text',
|
|
76
|
+
class: 'insert-input',
|
|
77
|
+
placeholder: hint,
|
|
78
|
+
'aria-label': `${column.name} (${column.type})`,
|
|
79
|
+
autocomplete: 'off',
|
|
80
|
+
spellcheck: 'false',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
inputs.set(column.name, input);
|
|
84
|
+
|
|
85
|
+
return el('div', { class: 'insert-field' }, [
|
|
86
|
+
el('label', {}, [
|
|
87
|
+
document.createTextNode(column.name),
|
|
88
|
+
el('span', { class: 'type', text: column.type.toLowerCase() }),
|
|
89
|
+
column.primaryKey ? el('span', { class: 'pk', text: 'key' }) : null,
|
|
90
|
+
required ? el('span', { class: 'req', title: 'NOT NULL with no default', text: 'required' }) : null,
|
|
91
|
+
].filter(Boolean)),
|
|
92
|
+
input,
|
|
93
|
+
]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const save = async () => {
|
|
97
|
+
/* Only what was typed. An empty box is not the empty string — it is
|
|
98
|
+
"the database decides", which is what makes defaults and the
|
|
99
|
+
auto-assigned key work without this form knowing how to compute
|
|
100
|
+
either. */
|
|
101
|
+
const values = {};
|
|
102
|
+
for (const [name, input] of inputs) {
|
|
103
|
+
if (input.value !== '') values[name] = input.value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = await api('/api/insert', { table: table.id, values });
|
|
109
|
+
} catch (err) {
|
|
110
|
+
problem.textContent = err.message;
|
|
111
|
+
problem.hidden = false;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (result.errors?.length) {
|
|
115
|
+
problem.textContent = result.errors[0].message;
|
|
116
|
+
problem.hidden = false;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
dialog.close();
|
|
121
|
+
const label = result.row ? rowLabel(table, result.row) : Object.values(result.key).join('/');
|
|
122
|
+
toast(`Added ${table.name} · ${label}`, 'ok');
|
|
123
|
+
/* Straight to the new record: the form's answer is the row the database
|
|
124
|
+
stored — defaults filled, key assigned — and the record page is where
|
|
125
|
+
that answer is legible. */
|
|
126
|
+
go(rowView(table.id, result.key, label), 'push');
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const dialog = el('dialog', { class: 'confirm insert-dialog' }, [
|
|
130
|
+
el('h2', { text: `New ${table.name}` }),
|
|
131
|
+
el('p', {
|
|
132
|
+
class: 'insert-lede',
|
|
133
|
+
text: 'Empty fields are left to the database — defaults apply, keys are assigned.',
|
|
134
|
+
}),
|
|
135
|
+
el('div', { class: 'insert-fields' }, table.columns.map(field)),
|
|
136
|
+
problem,
|
|
137
|
+
el('div', { class: 'confirm-actions' }, [
|
|
138
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
|
|
139
|
+
el('button', { type: 'button', class: 'primary', text: 'Add row', onclick: () => void save() }),
|
|
140
|
+
]),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
dialog.addEventListener('close', () => dialog.remove());
|
|
144
|
+
document.body.append(dialog);
|
|
145
|
+
dialog.showModal();
|
|
146
|
+
/* The first box someone has to fill, not the first box. */
|
|
147
|
+
const firstRequired = table.columns.find(
|
|
148
|
+
(c) => !c.nullable && c.default === undefined && c.name !== assigned,
|
|
149
|
+
);
|
|
150
|
+
(inputs.get(firstRequired?.name) ?? inputs.values().next().value)?.focus();
|
|
151
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overflow menu, and the registry behind it.
|
|
3
|
+
*
|
|
4
|
+
* The toolbar had six buttons of equal weight — Run, SQL, Recent, Schema,
|
|
5
|
+
* Views, Read only — which is six things with no order of importance between
|
|
6
|
+
* them. Two of them were duplicates of something else (Schema is what the
|
|
7
|
+
* wordmark already does; Recent is what ⌘K now covers), one is a mode rather
|
|
8
|
+
* than an action, and one is the primary action. Flattening all of that into
|
|
9
|
+
* a row of identical outlined rectangles makes the reader read every label
|
|
10
|
+
* every time to find the one they want.
|
|
11
|
+
*
|
|
12
|
+
* So: `Run` stays attached to the field it runs, the mode sits apart at the
|
|
13
|
+
* far end because it is a state and not a verb, and everything else lives
|
|
14
|
+
* behind one button. The registry exists so a module can add its own item
|
|
15
|
+
* without reaching into the header — the composer adds "Views…" the same way
|
|
16
|
+
* the app adds "Show SQL", and neither needs to know the other exists.
|
|
17
|
+
*/
|
|
18
|
+
import { $, el } from './core.js';
|
|
19
|
+
|
|
20
|
+
/** @type {Array<{label: string, onSelect: () => void, checked?: () => boolean, detail?: string, group?: string}>} */
|
|
21
|
+
const items = [];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register a menu item. Order of registration is order of appearance.
|
|
25
|
+
*
|
|
26
|
+
* `checked` makes the item a toggle: it is read each time the menu opens, so
|
|
27
|
+
* an item reflects the current state rather than the state at registration.
|
|
28
|
+
*/
|
|
29
|
+
export function addMenuItem(item) {
|
|
30
|
+
items.push(item);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Replace every item in a group.
|
|
35
|
+
*
|
|
36
|
+
* Registration is a one-time act for the fixed commands, and wrong for the
|
|
37
|
+
* ones that come from data: the pages this connection has change when someone
|
|
38
|
+
* builds one, deletes one, or switches database. Adding them again would
|
|
39
|
+
* leave the old ones beside the new, and a page you deleted still listed in
|
|
40
|
+
* ⌘K is a command that opens nothing.
|
|
41
|
+
*
|
|
42
|
+
* By group rather than by id, because "these are the pages now" is the whole
|
|
43
|
+
* statement — anything previously in the group that is not in the list is
|
|
44
|
+
* gone, which is the part a per-item update cannot say.
|
|
45
|
+
*/
|
|
46
|
+
/**
|
|
47
|
+
* Who to tell when the registered commands change.
|
|
48
|
+
*
|
|
49
|
+
* The palette snapshots the commands when it opens, and a group registered
|
|
50
|
+
* a beat later — pages arriving from the server on a fresh load — was
|
|
51
|
+
* invisible until it was closed and reopened. Searching for a page you can
|
|
52
|
+
* see in the menu and being told nothing matches is the palette lying.
|
|
53
|
+
*/
|
|
54
|
+
const watchers = new Set();
|
|
55
|
+
|
|
56
|
+
/** Register for command-list changes. Returns the way to stop listening. */
|
|
57
|
+
export function whenMenuChanges(run) {
|
|
58
|
+
watchers.add(run);
|
|
59
|
+
return () => watchers.delete(run);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function setMenuGroup(group, entries) {
|
|
63
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
64
|
+
if (items[i].group === group) items.splice(i, 1);
|
|
65
|
+
}
|
|
66
|
+
items.push(...entries.map((entry) => ({ ...entry, group })));
|
|
67
|
+
for (const run of [...watchers]) run();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Everything registered, for anywhere else that wants to offer it.
|
|
72
|
+
*
|
|
73
|
+
* The command palette lists these alongside tables, so an action is reachable
|
|
74
|
+
* by typing its name as well as by opening the menu — registered once, offered
|
|
75
|
+
* twice. Two lists of commands maintained separately is how a menu item ends
|
|
76
|
+
* up missing from search.
|
|
77
|
+
*/
|
|
78
|
+
export const menuItems = () => items;
|
|
79
|
+
|
|
80
|
+
/** A menu item's detail, which may be a function of the moment. */
|
|
81
|
+
export const detailOf = (item) =>
|
|
82
|
+
(typeof item.detail === 'function' ? item.detail() : item.detail);
|
|
83
|
+
|
|
84
|
+
let list = null;
|
|
85
|
+
|
|
86
|
+
function close() {
|
|
87
|
+
if (!list || list.hidden) return;
|
|
88
|
+
list.hidden = true;
|
|
89
|
+
$('more-button')?.setAttribute('aria-expanded', 'false');
|
|
90
|
+
document.removeEventListener('pointerdown', onOutside, true);
|
|
91
|
+
document.removeEventListener('keydown', onKey, true);
|
|
92
|
+
window.removeEventListener('scroll', close, true);
|
|
93
|
+
window.removeEventListener('resize', close);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function onOutside(e) {
|
|
97
|
+
if (!list.contains(e.target) && e.target !== $('more-button')) close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onKey(e) {
|
|
101
|
+
if (e.key === 'Escape') {
|
|
102
|
+
// Stopped as well: see the palette. An open menu owns the key.
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
e.stopPropagation();
|
|
105
|
+
close();
|
|
106
|
+
$('more-button')?.focus();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function open() {
|
|
111
|
+
const button = $('more-button');
|
|
112
|
+
list.replaceChildren(...items.map((item, i) => {
|
|
113
|
+
const checked = item.checked?.() ?? null;
|
|
114
|
+
return el('div', {
|
|
115
|
+
class: `more-item${checked ? ' checked' : ''}`,
|
|
116
|
+
role: checked === null ? 'menuitem' : 'menuitemcheckbox',
|
|
117
|
+
'aria-checked': checked === null ? undefined : String(checked),
|
|
118
|
+
tabindex: -1,
|
|
119
|
+
onclick: () => { close(); item.onSelect(); },
|
|
120
|
+
onkeydown: (e) => {
|
|
121
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); close(); item.onSelect(); }
|
|
122
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); list.children[i + 1]?.focus(); }
|
|
123
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); list.children[i - 1]?.focus(); }
|
|
124
|
+
},
|
|
125
|
+
}, [
|
|
126
|
+
// A tick column, always present, so labels line up whether or not the
|
|
127
|
+
// item is a toggle.
|
|
128
|
+
el('span', { class: 'more-tick', 'aria-hidden': 'true', text: checked ? '✓' : '' }),
|
|
129
|
+
el('span', { class: 'more-label', text: item.label }),
|
|
130
|
+
/* Read when the menu opens, not when the item was registered, so a
|
|
131
|
+
detail that describes current state — "credit_limit on customer 4" —
|
|
132
|
+
is the state now rather than the state at boot. */
|
|
133
|
+
detailOf(item) ? el('span', { class: 'more-detail', text: detailOf(item) }) : null,
|
|
134
|
+
].filter(Boolean));
|
|
135
|
+
}));
|
|
136
|
+
|
|
137
|
+
list.hidden = false;
|
|
138
|
+
button.setAttribute('aria-expanded', 'true');
|
|
139
|
+
|
|
140
|
+
// Fixed to the viewport, like every other menu here, so a scrolling
|
|
141
|
+
// ancestor cannot clip it.
|
|
142
|
+
const box = button.getBoundingClientRect();
|
|
143
|
+
list.style.position = 'fixed';
|
|
144
|
+
list.style.top = `${box.bottom + 6}px`;
|
|
145
|
+
list.style.right = `${Math.max(8, window.innerWidth - box.right)}px`;
|
|
146
|
+
|
|
147
|
+
list.children[0]?.focus();
|
|
148
|
+
document.addEventListener('pointerdown', onOutside, true);
|
|
149
|
+
document.addEventListener('keydown', onKey, true);
|
|
150
|
+
window.addEventListener('scroll', close, true);
|
|
151
|
+
window.addEventListener('resize', close);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function initMenu() {
|
|
155
|
+
const button = $('more-button');
|
|
156
|
+
if (!button) return;
|
|
157
|
+
list = el('div', { class: 'more-list', role: 'menu', hidden: true });
|
|
158
|
+
document.body.append(list);
|
|
159
|
+
button.addEventListener('click', () => (list.hidden ? open() : close()));
|
|
160
|
+
}
|