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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The screen for someone who has nothing connected yet.
|
|
3
|
+
*
|
|
4
|
+
* Until now `tablewalk` with no database printed its usage and exited(1). The
|
|
5
|
+
* flags were all correct and it answered the wrong question: somebody who has
|
|
6
|
+
* just installed a thing called tablewalk to find out what it is gets a wall
|
|
7
|
+
* of options and no program. Worse, the tool already owned the answer — a
|
|
8
|
+
* dialog that takes a connection string or its parts, tests it before keeping
|
|
9
|
+
* it, and resolves the password from the environment or the keychain — and it
|
|
10
|
+
* was one successful startup out of reach.
|
|
11
|
+
*
|
|
12
|
+
* So the server starts with nothing and this asks. Three things it is careful
|
|
13
|
+
* about:
|
|
14
|
+
*
|
|
15
|
+
* - It does not pretend to be the app. The query bar, the table list and
|
|
16
|
+
* the write toggle are all controls over a database, and leaving them on
|
|
17
|
+
* screen greyed or live-but-broken is an interface saying "try me" about
|
|
18
|
+
* things that cannot work yet. They are hidden until there is something
|
|
19
|
+
* for them to act on.
|
|
20
|
+
*
|
|
21
|
+
* - It says what the tool is before asking for credentials. "Paste your
|
|
22
|
+
* production connection string" is a large request from a program that
|
|
23
|
+
* has not yet said what it does with it, so the read-only default and the
|
|
24
|
+
* fact that nothing is written to disk are on the screen making the ask,
|
|
25
|
+
* not in a README.
|
|
26
|
+
*
|
|
27
|
+
* - It reloads rather than booting a second way. Once a connection exists,
|
|
28
|
+
* the ordinary start path is the one that has every other feature wired
|
|
29
|
+
* into it; assembling half of it again here is how the two would drift.
|
|
30
|
+
* A reload of a local page is imperceptible and cannot disagree with
|
|
31
|
+
* itself.
|
|
32
|
+
*/
|
|
33
|
+
import { $, api, el } from './core.js';
|
|
34
|
+
import { addConnection, openConnections } from './connmanager.js';
|
|
35
|
+
|
|
36
|
+
/** The shapes a target can take, kept in the order they are likely to be met. */
|
|
37
|
+
const EXAMPLES = [
|
|
38
|
+
{ label: 'PostgreSQL', text: 'postgres://user@localhost:5432/appdb' },
|
|
39
|
+
{ label: 'MySQL or MariaDB', text: 'mysql://user@localhost:3306/appdb' },
|
|
40
|
+
{ label: 'SQLite', text: './data/app.db' },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What the server has, for the screen that runs when none of it opened.
|
|
45
|
+
*
|
|
46
|
+
* Returns the list, not a yes/no. "Is there anything?" was the wrong
|
|
47
|
+
* question: a first connection that fails to open is listed and does not
|
|
48
|
+
* open, so the answer was yes and the welcome screen was skipped — leaving
|
|
49
|
+
* "Could not read the schema: No connection is open." on screen with nothing
|
|
50
|
+
* to click. The right question is what is there, so the screen can say.
|
|
51
|
+
*/
|
|
52
|
+
export async function listConnections() {
|
|
53
|
+
try {
|
|
54
|
+
const data = await api('/api/connections');
|
|
55
|
+
return data.connections ?? [];
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Draw it.
|
|
63
|
+
*
|
|
64
|
+
* Nothing is initialised beyond this point on purpose — no palette, no
|
|
65
|
+
* shortcuts, no undo. Every one of them reads `state.schema`, and there
|
|
66
|
+
* isn't one.
|
|
67
|
+
*/
|
|
68
|
+
export function showWelcome(listed = []) {
|
|
69
|
+
document.body.classList.add('first-run');
|
|
70
|
+
document.title = 'tablewalk';
|
|
71
|
+
$('db-label').textContent = 'No database';
|
|
72
|
+
|
|
73
|
+
const button = el('button', {
|
|
74
|
+
type: 'button',
|
|
75
|
+
class: 'welcome-go',
|
|
76
|
+
text: 'Connect a database',
|
|
77
|
+
onclick: () => addConnection(() => window.location.reload()),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/* Connections that exist and would not open.
|
|
81
|
+
|
|
82
|
+
Without this the screen said "nothing connected" over a list of things
|
|
83
|
+
that were connected-ish and broken, and offered adding another as the
|
|
84
|
+
only move — when the useful move is almost always to fix or remove the
|
|
85
|
+
one that failed. It is the whole reason this screen is reachable with a
|
|
86
|
+
non-empty list at all. */
|
|
87
|
+
const broken = listed.filter((c) => c.error);
|
|
88
|
+
const trouble = broken.length ? el('div', { class: 'welcome-trouble' }, [
|
|
89
|
+
el('p', { class: 'welcome-trouble-head' }, [
|
|
90
|
+
el('strong', {
|
|
91
|
+
text: broken.length === 1
|
|
92
|
+
? 'One connection is configured, and it did not open.'
|
|
93
|
+
: `${broken.length} connections are configured, and none of them opened.`,
|
|
94
|
+
}),
|
|
95
|
+
]),
|
|
96
|
+
el('ul', {}, broken.map((c) => el('li', {}, [
|
|
97
|
+
el('span', { class: 'welcome-trouble-name', text: c.name }),
|
|
98
|
+
el('span', { class: 'welcome-trouble-why', text: c.error }),
|
|
99
|
+
]))),
|
|
100
|
+
el('button', {
|
|
101
|
+
type: 'button',
|
|
102
|
+
class: 'ghost',
|
|
103
|
+
text: 'Open connections',
|
|
104
|
+
onclick: () => void openConnections(),
|
|
105
|
+
}),
|
|
106
|
+
]) : null;
|
|
107
|
+
|
|
108
|
+
$('content').replaceChildren(el('div', { class: 'welcome' }, [
|
|
109
|
+
/* The promise, not a status report.
|
|
110
|
+
|
|
111
|
+
This read "Nothing connected yet." — accurate, and the software's point
|
|
112
|
+
of view rather than the reader's: the first sentence somebody meets
|
|
113
|
+
named an absence at the moment they wanted to know what they had found.
|
|
114
|
+
The absence is already on the screen twice over, in the top bar and in
|
|
115
|
+
the one button, so the heading does not have to carry it.
|
|
116
|
+
|
|
117
|
+
It is the README's own line, and deliberately. The wordmark is in the
|
|
118
|
+
top bar directly above, which makes this the same pairing the front
|
|
119
|
+
page opens with — a product whose first screen and first paragraph say
|
|
120
|
+
different things about what it is for has two pitches, and one of them
|
|
121
|
+
is wrong. */
|
|
122
|
+
el('h1', { text: 'Follow the data.' }),
|
|
123
|
+
el('p', { class: 'welcome-lede' }, [
|
|
124
|
+
/* Two plain sentences with an example in them.
|
|
125
|
+
|
|
126
|
+
This was "A database is a graph, and this walks it: open a row and
|
|
127
|
+
you get every row elsewhere that points at it, without writing a join
|
|
128
|
+
to reach them. Give it something to read." — an abstract reframe, a
|
|
129
|
+
colon, a clause defending against an objection nobody had raised
|
|
130
|
+
yet, and a closing instruction for a button that is four inches
|
|
131
|
+
below and already says the same thing. Naming two real rows does more
|
|
132
|
+
than any of that. */
|
|
133
|
+
document.createTextNode('Open any row and you see what points at it. '
|
|
134
|
+
+ 'The orders on a customer, the customer on an order.'),
|
|
135
|
+
]),
|
|
136
|
+
button,
|
|
137
|
+
trouble,
|
|
138
|
+
el('p', { class: 'welcome-assure' }, [
|
|
139
|
+
/* The two facts most likely to be someone's actual hesitation, said
|
|
140
|
+
before the box that asks for a password rather than after. */
|
|
141
|
+
el('strong', { text: 'Read only.' }),
|
|
142
|
+
document.createTextNode(' You turn editing on yourself, per connection. '
|
|
143
|
+
+ 'Connections you add here are gone when you quit, unless you save them to a file.'),
|
|
144
|
+
]),
|
|
145
|
+
el('div', { class: 'welcome-examples' }, [
|
|
146
|
+
el('h2', { text: 'What to point it at' }),
|
|
147
|
+
el('ul', {}, EXAMPLES.map((e) => el('li', {}, [
|
|
148
|
+
el('span', { class: 'welcome-ex-label', text: e.label }),
|
|
149
|
+
el('code', { text: e.text }),
|
|
150
|
+
]))),
|
|
151
|
+
el('p', { class: 'note' }, [
|
|
152
|
+
document.createTextNode('The password does not have to be in the string. Write '),
|
|
153
|
+
el('code', { text: '${DB_PASSWORD}' }),
|
|
154
|
+
document.createTextNode(' and it comes from the environment, or leave it out and keep it in '
|
|
155
|
+
+ 'the keychain. There is a Test button either way.'),
|
|
156
|
+
]),
|
|
157
|
+
el('p', { class: 'note' }, [
|
|
158
|
+
document.createTextNode('To skip this next time, put them in a '),
|
|
159
|
+
el('code', { text: 'tablewalk.json' }),
|
|
160
|
+
document.createTextNode(' where you start tablewalk:'),
|
|
161
|
+
]),
|
|
162
|
+
el('pre', {
|
|
163
|
+
class: 'welcome-config',
|
|
164
|
+
text: '{\n "connections": [\n { "name": "app", "url": "postgres://localhost/appdb" }\n ]\n}',
|
|
165
|
+
}),
|
|
166
|
+
]),
|
|
167
|
+
]));
|
|
168
|
+
|
|
169
|
+
button.focus();
|
|
170
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write mode.
|
|
3
|
+
*
|
|
4
|
+
* Read is the default and stays the default: the connection the browsing path
|
|
5
|
+
* uses is opened read-only and never changes, so nothing you do while looking
|
|
6
|
+
* around can write, even after the toggle is on. Editing goes through a
|
|
7
|
+
* separate connection that the server opens lazily, the first time it is
|
|
8
|
+
* actually asked to write.
|
|
9
|
+
*
|
|
10
|
+
* The toggle is deliberately not sticky. It lives in memory for the session
|
|
11
|
+
* and resets on reload, because a mode that silently persists is a mode you
|
|
12
|
+
* forget you are in — and the whole risk of an editor is a change you did not
|
|
13
|
+
* realise you were making.
|
|
14
|
+
*
|
|
15
|
+
* The real guard is on the server, not here. This file makes the state
|
|
16
|
+
* obvious and asks for confirmation; the server independently refuses any
|
|
17
|
+
* update whose WHERE clause does not match exactly one row.
|
|
18
|
+
*/
|
|
19
|
+
import { $, api, el, loadJson, render, rowLabel, saveJson, state, toast } from './core.js';
|
|
20
|
+
import { planColumn } from './columns.js';
|
|
21
|
+
import { refreshUndo, undoLast } from './undo.js';
|
|
22
|
+
|
|
23
|
+
/* Write mode is remembered per connection.
|
|
24
|
+
Per connection, not globally: switching from the demo to production must
|
|
25
|
+
not silently inherit permission to edit it. Each database is opted into
|
|
26
|
+
separately, and the choice sticks until it is turned off. */
|
|
27
|
+
const WRITE_KEY = 'tablewalk.write.v1';
|
|
28
|
+
|
|
29
|
+
const connectionKey = () => state.activeConnection ?? 'default';
|
|
30
|
+
|
|
31
|
+
function rememberedWrite() {
|
|
32
|
+
return loadJson(WRITE_KEY, {})[connectionKey()] === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rememberWrite(on) {
|
|
36
|
+
const all = loadJson(WRITE_KEY, {});
|
|
37
|
+
if (on) all[connectionKey()] = true;
|
|
38
|
+
else delete all[connectionKey()];
|
|
39
|
+
saveJson(WRITE_KEY, all);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function initWriteMode() {
|
|
43
|
+
const toggle = $('write-toggle');
|
|
44
|
+
toggle.addEventListener('click', () => {
|
|
45
|
+
if (state.canWrite) {
|
|
46
|
+
setWriteMode(false);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
confirmWrite().then((ok) => ok && setWriteMode(true));
|
|
50
|
+
});
|
|
51
|
+
// Restored without asking again: the confirmation was given for this
|
|
52
|
+
// database and has not been withdrawn.
|
|
53
|
+
setWriteMode(rememberedWrite(), { remember: false });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Re-evaluate on connection switch, since the setting is per database. */
|
|
57
|
+
export function syncWriteMode() {
|
|
58
|
+
setWriteMode(rememberedWrite(), { remember: false });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function setWriteMode(on, { remember = true } = {}) {
|
|
62
|
+
state.canWrite = on;
|
|
63
|
+
if (remember) rememberWrite(on);
|
|
64
|
+
const toggle = $('write-toggle');
|
|
65
|
+
toggle.setAttribute('aria-pressed', String(on));
|
|
66
|
+
/* An icon, but not a quiet one.
|
|
67
|
+
|
|
68
|
+
Write mode is the one state in this header worth noticing without being
|
|
69
|
+
looked for, so the two states are drawn as opposites rather than as two
|
|
70
|
+
similar glyphs: a closed padlock, dim and unfilled, against an open one
|
|
71
|
+
that is filled and coloured. The label goes to assistive technology
|
|
72
|
+
rather than disappearing — an icon-only control still has to say what it
|
|
73
|
+
is. */
|
|
74
|
+
toggle.textContent = on ? '🔓' : '🔒';
|
|
75
|
+
toggle.setAttribute('aria-label', on ? 'Write mode — click to return to read only' : 'Read only — click to enable editing');
|
|
76
|
+
toggle.title = on
|
|
77
|
+
? 'Editing is enabled. Click to return to read only.'
|
|
78
|
+
: 'Browsing only. Click to enable editing.';
|
|
79
|
+
/* The indicator is on <body> rather than the button, so it can colour the
|
|
80
|
+
window frame. Being in write mode should be visible from across the room,
|
|
81
|
+
not something you have to go looking for. */
|
|
82
|
+
document.body.classList.toggle('write-mode', on);
|
|
83
|
+
// Leaving write mode drops anything staged: pending edits you can no longer
|
|
84
|
+
// see or apply are worse than no pending edits.
|
|
85
|
+
if (!on) discardStaged();
|
|
86
|
+
render();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A real confirmation, naming the database. "Are you sure?" teaches nothing;
|
|
91
|
+
* the name of the thing you are about to be able to change does.
|
|
92
|
+
*/
|
|
93
|
+
function confirmWrite() {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const dialog = el('dialog', { class: 'confirm' }, [
|
|
96
|
+
el('h2', { text: 'Enable write mode?' }),
|
|
97
|
+
el('p', {}, [
|
|
98
|
+
document.createTextNode('You will be able to edit rows in '),
|
|
99
|
+
el('strong', { text: state.schema.label }),
|
|
100
|
+
document.createTextNode('. Changes go straight to the database and cannot be undone from here.'),
|
|
101
|
+
]),
|
|
102
|
+
el('ul', { class: 'confirm-terms' }, [
|
|
103
|
+
el('li', { text: 'Only rows with a primary key can be edited — one row, one column at a time.' }),
|
|
104
|
+
el('li', { text: 'Every change is shown as SQL before it runs.' }),
|
|
105
|
+
el('li', { text: 'An update that would touch more than one row is refused.' }),
|
|
106
|
+
el('li', { text: 'Write mode stays on for this database until you turn it off, including after a reload.' }),
|
|
107
|
+
el('li', { text: 'It applies to this database only — switching connections does not carry it over.' }),
|
|
108
|
+
]),
|
|
109
|
+
el('div', { class: 'confirm-actions' }, [
|
|
110
|
+
el('button', { type: 'button', class: 'ghost', text: 'Stay read only', onclick: () => { dialog.close(); resolve(false); } }),
|
|
111
|
+
el('button', { type: 'button', class: 'danger', text: 'Enable write mode', onclick: () => { dialog.close(); resolve(true); } }),
|
|
112
|
+
]),
|
|
113
|
+
]);
|
|
114
|
+
dialog.addEventListener('close', () => dialog.remove());
|
|
115
|
+
dialog.addEventListener('cancel', () => resolve(false));
|
|
116
|
+
document.body.append(dialog);
|
|
117
|
+
dialog.showModal();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ---------- staged edits ----------
|
|
122
|
+
|
|
123
|
+
An edit does not go to the database when you leave the field. It is held
|
|
124
|
+
until you apply, so several fields become one statement — three edits as
|
|
125
|
+
three statements means three chances to fail half way, leaving a row in a
|
|
126
|
+
state nobody asked for.
|
|
127
|
+
|
|
128
|
+
Staging is keyed by table and row, so walking away and back does not
|
|
129
|
+
silently carry pending edits onto a different row. */
|
|
130
|
+
|
|
131
|
+
let staged = { connection: null, table: null, key: null, values: {}, originals: {}, inputs: {} };
|
|
132
|
+
|
|
133
|
+
export function pendingCount() {
|
|
134
|
+
return Object.keys(staged.values).length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether what is staged belongs to the row on screen.
|
|
139
|
+
*
|
|
140
|
+
* The connection is part of the answer. Keyed by table and key alone, an edit
|
|
141
|
+
* staged against staging's `customer:4` showed as pending on production's
|
|
142
|
+
* `customer:4` — the same table name and the same id, a different database,
|
|
143
|
+
* and one click from being written to the wrong one.
|
|
144
|
+
*/
|
|
145
|
+
function sameRow(table, key) {
|
|
146
|
+
return staged.connection === state.activeConnection
|
|
147
|
+
&& staged.table === table
|
|
148
|
+
&& JSON.stringify(staged.key) === JSON.stringify(key);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function discardStaged() {
|
|
152
|
+
staged = { connection: null, table: null, key: null, values: {}, originals: {}, inputs: {} };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function stage(table, key, column, value, original) {
|
|
156
|
+
if (!sameRow(table.id, key)) {
|
|
157
|
+
staged = {
|
|
158
|
+
connection: state.activeConnection, table: table.id, key,
|
|
159
|
+
values: {}, originals: {}, inputs: {},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// Typing a value and then typing the original back is not a change. Keeping
|
|
163
|
+
// it staged would show "1 change" for an edit that changes nothing.
|
|
164
|
+
if (String(value) === String(original) || (value === null && original === null)) {
|
|
165
|
+
delete staged.values[column.name];
|
|
166
|
+
delete staged.originals[column.name];
|
|
167
|
+
} else {
|
|
168
|
+
staged.values[column.name] = value;
|
|
169
|
+
staged.originals[column.name] = original;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The two values a boolean column actually holds, or null if it is not one.
|
|
175
|
+
*
|
|
176
|
+
* MySQL has no boolean — BOOL is a spelling of TINYINT(1) — and SQLite keeps
|
|
177
|
+
* 0 and 1 in an INTEGER, so the pair depends on the column rather than on a
|
|
178
|
+
* type name. Written back exactly as it was read, which is what stops an
|
|
179
|
+
* edit to a *different* field of the same row from rewriting `1` to `true`
|
|
180
|
+
* on the way past.
|
|
181
|
+
*/
|
|
182
|
+
function booleanChoices(column, row) {
|
|
183
|
+
/* The grid's own rule, not a second one: a column drawn as ✓/✗ has to edit
|
|
184
|
+
as yes/no, or the same value is a tick in one view and a `0` to type in
|
|
185
|
+
the other. That rule is more than the declared type — MySQL has no
|
|
186
|
+
boolean and SQLite keeps flags in INTEGER, so a name like `is_urgent`
|
|
187
|
+
holding only 0 and 1 counts. */
|
|
188
|
+
const plan = planColumn(column, column?.name ?? '', row ? [row] : []);
|
|
189
|
+
if (plan.kind !== 'boolean') return null;
|
|
190
|
+
return /bool/i.test(column?.type ?? '') ? { yes: 'true', no: 'false' } : { yes: '1', no: '0' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Turn a field into an editor. Called from the record view, and only when
|
|
195
|
+
* write mode is on and the row has a usable key.
|
|
196
|
+
*/
|
|
197
|
+
export function editable(table, row, column, key, onSaved) {
|
|
198
|
+
const value = row[column.name];
|
|
199
|
+
const original = value === null || value === undefined ? null : String(value);
|
|
200
|
+
const stagedValue = sameRow(table.id, key) ? staged.values[column.name] : undefined;
|
|
201
|
+
const shown = stagedValue !== undefined ? stagedValue : original;
|
|
202
|
+
|
|
203
|
+
/* A box you type `0` or `1` into is not an editor for a column that holds
|
|
204
|
+
two values. The choices are the values the database stores — `1` and `0`
|
|
205
|
+
where the column is an integer flag, `true` and `false` where it is a
|
|
206
|
+
real boolean — so what is written is what was already there, and the
|
|
207
|
+
empty option is NULL rather than a third state. */
|
|
208
|
+
const choices = booleanChoices(column, row);
|
|
209
|
+
const input = choices
|
|
210
|
+
? el('select', {
|
|
211
|
+
class: `cell-edit${stagedValue !== undefined ? ' dirty' : ''}`,
|
|
212
|
+
'aria-label': `${column.name} (${column.type})`,
|
|
213
|
+
}, [
|
|
214
|
+
...(column.nullable ? [el('option', { value: '', text: 'null' })] : []),
|
|
215
|
+
el('option', { value: choices.yes, text: 'yes' }),
|
|
216
|
+
el('option', { value: choices.no, text: 'no' }),
|
|
217
|
+
])
|
|
218
|
+
: el('input', {
|
|
219
|
+
class: `cell-edit${stagedValue !== undefined ? ' dirty' : ''}`,
|
|
220
|
+
/* Named by its column. A record page is a grid of unlabelled text boxes
|
|
221
|
+
to anything that cannot see the `dt` beside each one, and "edit field"
|
|
222
|
+
repeated forty times is no better than nothing. */
|
|
223
|
+
'aria-label': `${column.name} (${column.type})`,
|
|
224
|
+
type: 'text',
|
|
225
|
+
value: shown === null ? '' : String(shown),
|
|
226
|
+
'data-null': String(shown === null),
|
|
227
|
+
'aria-label': `${column.name} (${column.type})`,
|
|
228
|
+
/* Where the database pins the vocabulary, say it — but still a text box.
|
|
229
|
+
A column that gained a CHECK after the rows were written holds values
|
|
230
|
+
the check would refuse, and a select would silently rewrite one of
|
|
231
|
+
them the moment someone edited a different field of that row. */
|
|
232
|
+
title: column.allowed?.length ? `one of: ${column.allowed.join(', ')}` : undefined,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (choices) input.value = shown === null ? '' : String(shown);
|
|
236
|
+
|
|
237
|
+
const onEdit = () => {
|
|
238
|
+
// An empty box on a nullable column means NULL, not the empty string.
|
|
239
|
+
// They are different values and conflating them loses data silently.
|
|
240
|
+
const next = input.value === '' && column.nullable ? null : input.value;
|
|
241
|
+
stage(table, key, column, next, original);
|
|
242
|
+
input.classList.toggle('dirty', staged.values[column.name] !== undefined);
|
|
243
|
+
onSaved?.();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Kept so a successful write can confirm in place rather than re-render.
|
|
247
|
+
if (!sameRow(table.id, key)) staged = { table: table.id, key, values: {}, originals: {}, inputs: {} };
|
|
248
|
+
staged.inputs[column.name] = input;
|
|
249
|
+
|
|
250
|
+
/* `input` only, deliberately: a `change` listener fires on blur, which is
|
|
251
|
+
the moment someone clicks "Review & apply" — the re-render it triggered
|
|
252
|
+
detached the button mid-click and the confirmation never opened. Selects
|
|
253
|
+
fire `input` too, so nothing is lost. */
|
|
254
|
+
input.addEventListener('input', onEdit);
|
|
255
|
+
input.addEventListener('keydown', (e) => {
|
|
256
|
+
if (e.key === 'Enter') {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
if (pendingCount()) void applyStaged(table, key, onSaved);
|
|
259
|
+
}
|
|
260
|
+
if (e.key === 'Escape') {
|
|
261
|
+
input.value = original === null ? '' : original;
|
|
262
|
+
onEdit();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
return input;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The pending-changes bar. Rendered by the record view above the fields, so
|
|
270
|
+
* the fact that edits are held rather than saved is impossible to miss.
|
|
271
|
+
*/
|
|
272
|
+
export function pendingBar(table, key, onDone) {
|
|
273
|
+
if (!sameRow(table.id, key) || !pendingCount()) return null;
|
|
274
|
+
const n = pendingCount();
|
|
275
|
+
return el('div', { class: 'pending' }, [
|
|
276
|
+
el('span', { class: 'pending-count', text: `${n} unsaved change${n === 1 ? '' : 's'}` }),
|
|
277
|
+
el('span', { class: 'pending-cols', text: Object.keys(staged.values).join(', ') }),
|
|
278
|
+
el('button', {
|
|
279
|
+
type: 'button', class: 'ghost', text: 'Discard',
|
|
280
|
+
onclick: () => { discardStaged(); onDone?.(); },
|
|
281
|
+
}),
|
|
282
|
+
el('button', {
|
|
283
|
+
type: 'button', class: 'danger', text: `Review & apply`,
|
|
284
|
+
onclick: () => void applyStaged(table, key, onDone),
|
|
285
|
+
}),
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function applyStaged(table, key, onDone) {
|
|
290
|
+
if (!pendingCount()) return;
|
|
291
|
+
const values = { ...staged.values };
|
|
292
|
+
const originals = { ...staged.originals };
|
|
293
|
+
const ok = await confirmUpdate(table, key, values, originals);
|
|
294
|
+
if (!ok) return;
|
|
295
|
+
try {
|
|
296
|
+
const result = await api('/api/update', { table: table.id, key, values });
|
|
297
|
+
const n = Object.keys(result.applied).length;
|
|
298
|
+
|
|
299
|
+
/* Recorded before the toast, so the offer to undo cannot appear for a
|
|
300
|
+
write the history does not have. */
|
|
301
|
+
/* The server journalled the write as it landed; this just re-reads what
|
|
302
|
+
there is to undo, so the menu and ⌘Z are right immediately. */
|
|
303
|
+
void refreshUndo();
|
|
304
|
+
toast(`Updated ${n} column${n === 1 ? '' : 's'} on ${table.name}.`, 'ok', {
|
|
305
|
+
/* Beside the confirmation, because this is the moment someone realises
|
|
306
|
+
it was the wrong row — and sending them to a menu to find Undo wastes
|
|
307
|
+
the seconds in which they still remember what they changed. */
|
|
308
|
+
label: 'Undo',
|
|
309
|
+
run: () => void undoLast().then(() => render()),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
/* Confirmed in place rather than by re-rendering. A re-render would throw
|
|
313
|
+
away the row you are looking at and rebuild it, which reads as a flicker
|
|
314
|
+
and loses the caret; worse, it left the fields showing their unsaved
|
|
315
|
+
styling because the DOM they styled was gone before the class was.
|
|
316
|
+
Showing the saved value, in green, on the field you just edited is both
|
|
317
|
+
calmer and more specific — it says *which* fields landed. */
|
|
318
|
+
const inputs = staged.inputs;
|
|
319
|
+
confirmSaved(inputs, result.applied);
|
|
320
|
+
discardStaged();
|
|
321
|
+
// Only the pending bar is refreshed, so the green fields survive.
|
|
322
|
+
onDone?.({ inPlace: true });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
// Nothing is discarded on failure: the edits stay staged so they can be
|
|
325
|
+
// corrected rather than retyped.
|
|
326
|
+
toast(err.message, 'error');
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** How long the green confirmation stays before fading. */
|
|
331
|
+
const SAVED_MS = 2200;
|
|
332
|
+
|
|
333
|
+
function confirmSaved(inputs, applied) {
|
|
334
|
+
for (const [column, value] of Object.entries(applied)) {
|
|
335
|
+
const input = inputs[column];
|
|
336
|
+
if (!input || !input.isConnected) continue;
|
|
337
|
+
// The value the database actually stored, which may differ from what was
|
|
338
|
+
// typed — "17901" comes back as the number 17901.
|
|
339
|
+
input.value = value === null ? '' : String(value);
|
|
340
|
+
input.dataset.null = String(value === null);
|
|
341
|
+
input.classList.remove('dirty');
|
|
342
|
+
input.classList.add('saved');
|
|
343
|
+
setTimeout(() => input.classList.remove('saved'), SAVED_MS);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Show the statement before it runs. This is the last stop before a write. */
|
|
348
|
+
function confirmUpdate(table, key, values, originals) {
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
const q = (id) => `"${id}"`;
|
|
351
|
+
const sets = Object.keys(values).map((c) => `${q(c)} = ?`).join(',\n ');
|
|
352
|
+
const where = Object.keys(key).map((c) => `${q(c)} = ?`).join(' AND ');
|
|
353
|
+
const sql = `UPDATE ${q(table.id)}\n SET ${sets}\n WHERE ${where};`;
|
|
354
|
+
|
|
355
|
+
/* The parameters shown are the raw staged values. The server coerces them
|
|
356
|
+
against the declared column types and returns what it applied, so a
|
|
357
|
+
number typed as text is corrected there rather than guessed at here. */
|
|
358
|
+
const params = JSON.stringify([...Object.values(values), ...Object.values(key)]);
|
|
359
|
+
|
|
360
|
+
const rows = Object.entries(values).map(([column, to]) => {
|
|
361
|
+
const from = originals[column];
|
|
362
|
+
const type = table.columns.find((c) => c.name === column)?.type ?? '';
|
|
363
|
+
return el('tr', {}, [
|
|
364
|
+
el('td', {}, [
|
|
365
|
+
el('span', { class: 'diff-col', text: column }),
|
|
366
|
+
el('span', { class: 'diff-type', text: type.toLowerCase() }),
|
|
367
|
+
]),
|
|
368
|
+
el('td', { class: `diff-from${from === null ? ' null' : ''}`, text: from === null ? 'null' : String(from) }),
|
|
369
|
+
el('td', { class: 'diff-arrow', text: '→' }),
|
|
370
|
+
el('td', { class: `diff-to${to === null ? ' null' : ''}`, text: to === null ? 'null' : String(to) }),
|
|
371
|
+
]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const dialog = el('dialog', { class: 'confirm confirm-wide' }, [
|
|
375
|
+
el('h2', { text: rows.length === 1 ? 'Apply this change?' : `Apply ${rows.length} changes?` }),
|
|
376
|
+
el('p', {}, [
|
|
377
|
+
document.createTextNode('One row in '),
|
|
378
|
+
el('strong', { text: table.id }),
|
|
379
|
+
document.createTextNode(', identified by '),
|
|
380
|
+
el('strong', { text: Object.entries(key).map(([k, v]) => `${k} = ${v}`).join(', ') }),
|
|
381
|
+
document.createTextNode('. Applied as a single statement.'),
|
|
382
|
+
]),
|
|
383
|
+
el('table', { class: 'confirm-diff' }, el('tbody', {}, rows)),
|
|
384
|
+
el('pre', { class: 'ddl', text: sql }),
|
|
385
|
+
el('p', { class: 'confirm-params', text: `parameters ${params}` }),
|
|
386
|
+
el('div', { class: 'confirm-actions' }, [
|
|
387
|
+
el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => { dialog.close(); resolve(false); } }),
|
|
388
|
+
el('button', { type: 'button', class: 'danger', text: 'Apply', onclick: () => { dialog.close(); resolve(true); } }),
|
|
389
|
+
]),
|
|
390
|
+
]);
|
|
391
|
+
dialog.addEventListener('close', () => dialog.remove());
|
|
392
|
+
dialog.addEventListener('cancel', () => resolve(false));
|
|
393
|
+
document.body.append(dialog);
|
|
394
|
+
dialog.showModal();
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** True when this row can be edited at all: write mode on, and a full key. */
|
|
399
|
+
export function canEditRow(table, key) {
|
|
400
|
+
if (!state.canWrite || !table || table.isView) return false;
|
|
401
|
+
const pk = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
402
|
+
return pk.length > 0 && pk.every((c) => key[c] !== undefined && key[c] !== null);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Why a row is not editable, for the record view to explain rather than hide. */
|
|
406
|
+
export function whyNotEditable(table) {
|
|
407
|
+
if (!state.canWrite) return null; // not in write mode: say nothing
|
|
408
|
+
if (!table) return 'Unknown table.';
|
|
409
|
+
if (table.isView) return 'This is a view. Views are read-only.';
|
|
410
|
+
if (!table.columns.some((c) => c.primaryKey)) {
|
|
411
|
+
return 'This table has no primary key, so there is no way to identify one row safely.';
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|