mirror of
https://github.com/simonw/datasette.git
synced 2026-09-10 02:24:15 +02:00
Add <datasette-sql-editor> form-associated custom element
Light-DOM element built on createSqlEditor(): form participation via
ElementInternals (name= field, reset support), schema fetched from
{base-url}/{database}/-/editor-schema.json or schema-url= without ever
blocking editing, cancelable submit event on Mod/Shift-Enter driving
form.requestSubmit(), readOnly/value/schema/view properties, format()
via the sql-formatter global, theming through CSS custom properties
with appearance-preserving fallbacks. Auto-registers the tag on import
(guarded). Manual-QA page at demos/sql-editor-element.html.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
343e8258fb
commit
866b2d34e1
4 changed files with 464 additions and 3 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -272,7 +272,7 @@ function columnCompletion(name, type) {
|
|||
return completion;
|
||||
}
|
||||
|
||||
function schemaFromTables(tables) {
|
||||
export function schemaFromTables(tables) {
|
||||
const schema = {};
|
||||
for (const table of tables || []) {
|
||||
const completions = (table.columns || []).map((column) =>
|
||||
|
|
@ -309,6 +309,297 @@ export async function datasetteSchema(baseUrl, database) {
|
|||
return schemaFromTables(data.tables);
|
||||
}
|
||||
|
||||
// readOnlyState(ro) -> extensions that toggle editability. EditorState.readOnly
|
||||
// blocks document edits; EditorView.editable additionally drops the
|
||||
// contenteditable attribute so screen readers and the cursor reflect the
|
||||
// read-only state.
|
||||
function readOnlyState(ro) {
|
||||
return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
|
||||
}
|
||||
|
||||
// Theme that plumbs a small set of CSS custom properties into the editor so
|
||||
// embedders can restyle without reaching into CodeMirror internals. The fallbacks
|
||||
// reproduce Datasette's current editor appearance (monospace family, the
|
||||
// page-inherited font size, a transparent background over the page/plugin
|
||||
// background) so mounting the element on an existing Datasette page is visually a
|
||||
// no-op — including in dark mode, which Datasette implements entirely through the
|
||||
// surrounding page's colors, not editor-specific CSS. CodeMirror themes are static
|
||||
// CSS-in-JS, but var() references pass straight through to the generated rules and
|
||||
// resolve at render time, so an embedder's :root / @media (prefers-color-scheme)
|
||||
// overrides just work.
|
||||
const sqlEditorTheme = EditorView.theme({
|
||||
"&": {
|
||||
fontSize: "var(--datasette-sql-editor-font-size, inherit)",
|
||||
background: "var(--datasette-sql-editor-bg, transparent)",
|
||||
},
|
||||
".cm-content, .cm-gutters": {
|
||||
fontFamily: "var(--datasette-sql-editor-font-family, monospace)",
|
||||
},
|
||||
});
|
||||
|
||||
// <datasette-sql-editor> — a form-associated, light-DOM custom element wrapping
|
||||
// createSqlEditor(). The module auto-registers the default tag on import (see the
|
||||
// bottom of this file); call registerSqlEditorElement("my-tag") to also/instead
|
||||
// register it under a different name.
|
||||
//
|
||||
// Attributes (all optional):
|
||||
// name form-field name for the submitted SQL (form participation)
|
||||
// database Datasette database name; when set and schema-url is absent the
|
||||
// schema URL is derived as
|
||||
// {base-url}/{database}/-/editor-schema.json
|
||||
// base-url Datasette base_url prefix used for the derived schema URL ("")
|
||||
// schema-url explicit URL returning the neutral {tables:[...]} schema payload
|
||||
// default-table unqualified-column default table for autocomplete
|
||||
// readonly boolean; mounts the editor read-only
|
||||
// autofocus boolean; focuses the editor once mounted
|
||||
// The initial document is the element's trimmed textContent (cleared on mount).
|
||||
//
|
||||
// Properties:
|
||||
// value get/set the document (set is host-tagged: no "input" event)
|
||||
// schema set -> updateSchema({schema, defaultTable})
|
||||
// view get the raw EditorView escape hatch (null before mount)
|
||||
// readOnly get/set via a Compartment
|
||||
// extensions get/set extra CodeMirror extensions; honored ONLY before the
|
||||
// element connects (createSqlEditor builds the extension set once)
|
||||
// Methods: focus(), updateSchema(conf), format().
|
||||
// Events (all bubble):
|
||||
// input {detail:{origin:"user"}} on user edits (host edits suppressed)
|
||||
// submit cancelable; default action requestSubmit()s internals.form
|
||||
// ready once mounted (schema may still be fetching — see below)
|
||||
// editor-escape on Escape at the editor top level
|
||||
export class DatasetteSqlEditorElement extends HTMLElement {
|
||||
static formAssociated = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._handle = null;
|
||||
this._internals = null;
|
||||
this._readOnly = false;
|
||||
this._readOnlyCompartment = new Compartment();
|
||||
this._extensions = [];
|
||||
this._pendingDoc = null;
|
||||
this._initialDoc = "";
|
||||
// attachInternals is guarded: Safari < 16.4 has no ElementInternals. Without
|
||||
// it the editor still works fully, but form participation
|
||||
// (setFormValue/reset) is a no-op, so this field degrades to contributing
|
||||
// nothing on submit. Documented as an accepted graceful degradation.
|
||||
try {
|
||||
this._internals = this.attachInternals ? this.attachInternals() : null;
|
||||
} catch (err) {
|
||||
this._internals = null;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this._handle) return; // already mounted (e.g. move within the DOM)
|
||||
|
||||
const initialDoc =
|
||||
this._pendingDoc != null ? this._pendingDoc : this.textContent.trim();
|
||||
this._initialDoc = initialDoc;
|
||||
this._pendingDoc = null;
|
||||
// Clear the light-DOM text so it doesn't render behind the editor.
|
||||
this.textContent = "";
|
||||
|
||||
this._readOnly = this.hasAttribute("readonly");
|
||||
const defaultTable = this.getAttribute("default-table") || undefined;
|
||||
|
||||
this._handle = createSqlEditor(this, {
|
||||
doc: initialDoc,
|
||||
defaultTable,
|
||||
extensions: [
|
||||
sqlEditorTheme,
|
||||
this._readOnlyCompartment.of(readOnlyState(this._readOnly)),
|
||||
...(this._extensions || []),
|
||||
],
|
||||
onChange: () => {
|
||||
this._syncFormValue();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("input", {
|
||||
bubbles: true,
|
||||
detail: { origin: "user" },
|
||||
}),
|
||||
);
|
||||
},
|
||||
onSubmit: () => {
|
||||
const proceed = this.dispatchEvent(
|
||||
new CustomEvent("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
if (!proceed) return; // default prevented
|
||||
const form = this._internals && this._internals.form;
|
||||
if (!form) return;
|
||||
// requestSubmit() runs constraint validation and submit handlers, exactly
|
||||
// like clicking a submit button; fall back to submit() where unsupported.
|
||||
if (typeof form.requestSubmit === "function") {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
form.submit();
|
||||
}
|
||||
},
|
||||
onEscape: () => {
|
||||
this.dispatchEvent(new CustomEvent("editor-escape", { bubbles: true }));
|
||||
},
|
||||
});
|
||||
|
||||
this._syncFormValue();
|
||||
|
||||
// Fetch schema (if configured) without blocking the editor: a failure
|
||||
// downgrades to keyword-only completion and never breaks editing.
|
||||
const schemaUrl = this._resolveSchemaUrl();
|
||||
if (schemaUrl) {
|
||||
fetch(schemaUrl, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`schema fetch ${schemaUrl} -> ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
this.updateSchema({ schema: schemaFromTables(data.tables) });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
"datasette-sql-editor: schema fetch failed; keyword-only completion",
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasAttribute("autofocus")) {
|
||||
this._handle.view.focus();
|
||||
}
|
||||
|
||||
// "ready" fires after mount; schema may still be in flight (it applies later
|
||||
// via updateSchema). Dispatched synchronously so listeners attached before the
|
||||
// element is inserted observe it.
|
||||
this.dispatchEvent(new CustomEvent("ready", { bubbles: true }));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._handle) {
|
||||
// Preserve the document across DOM moves (disconnect + reconnect):
|
||||
// connectedCallback prefers _pendingDoc over textContent, and the dead
|
||||
// editor's DOM must not be left behind to be misread as initial content.
|
||||
this._pendingDoc = this._handle.value;
|
||||
this._handle.destroy();
|
||||
this._handle = null;
|
||||
this.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
formResetCallback() {
|
||||
if (!this._handle) return;
|
||||
this._handle.value = this._initialDoc; // hostChange-tagged: no "input" event
|
||||
this._syncFormValue();
|
||||
}
|
||||
|
||||
_resolveSchemaUrl() {
|
||||
const explicit = this.getAttribute("schema-url");
|
||||
if (explicit) return explicit;
|
||||
const database = this.getAttribute("database");
|
||||
if (!database) return null;
|
||||
const base = (this.getAttribute("base-url") || "").replace(/\/+$/, "");
|
||||
return `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
|
||||
}
|
||||
|
||||
_syncFormValue() {
|
||||
if (this._internals && this._internals.setFormValue) {
|
||||
this._internals.setFormValue(this.value);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- properties -------------------------------------------------------
|
||||
get value() {
|
||||
return this._handle ? this._handle.value : this._pendingDoc || "";
|
||||
}
|
||||
set value(newValue) {
|
||||
const v = newValue == null ? "" : String(newValue);
|
||||
if (this._handle) {
|
||||
this._handle.value = v; // hostChange-tagged: suppresses the "input" event
|
||||
this._syncFormValue();
|
||||
} else {
|
||||
this._pendingDoc = v;
|
||||
}
|
||||
}
|
||||
|
||||
set schema(ns) {
|
||||
this.updateSchema({ schema: ns });
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this._handle ? this._handle.view : null;
|
||||
}
|
||||
|
||||
get readOnly() {
|
||||
return this._readOnly;
|
||||
}
|
||||
set readOnly(value) {
|
||||
this._readOnly = !!value;
|
||||
if (this._handle) {
|
||||
this._handle.view.dispatch({
|
||||
effects: this._readOnlyCompartment.reconfigure(
|
||||
readOnlyState(this._readOnly),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get extensions() {
|
||||
return this._extensions;
|
||||
}
|
||||
set extensions(exts) {
|
||||
if (this._handle) {
|
||||
console.warn(
|
||||
"datasette-sql-editor: .extensions must be set before the element connects; ignoring",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._extensions = exts || [];
|
||||
}
|
||||
|
||||
// ---- methods ----------------------------------------------------------
|
||||
focus() {
|
||||
if (this._handle) this._handle.view.focus();
|
||||
}
|
||||
|
||||
updateSchema(conf = {}) {
|
||||
if (!this._handle) return;
|
||||
// Merge in default-table so a bare {schema} update doesn't drop it (the
|
||||
// compartment reconfigure replaces the whole sql() extension).
|
||||
this._handle.updateSchema({
|
||||
defaultTable: this.getAttribute("default-table") || undefined,
|
||||
...conf,
|
||||
});
|
||||
}
|
||||
|
||||
format() {
|
||||
const formatter =
|
||||
typeof window !== "undefined" ? window.sqlFormatter : undefined;
|
||||
if (!formatter || typeof formatter.format !== "function") {
|
||||
console.warn(
|
||||
"datasette-sql-editor: window.sqlFormatter is not loaded; format() is a no-op",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this._handle) return;
|
||||
const formatted = formatter.format(this.value);
|
||||
this._handle.value = formatted; // hostChange-tagged full replace
|
||||
this._syncFormValue();
|
||||
}
|
||||
}
|
||||
|
||||
// registerSqlEditorElement(tagName) — defines the element under tagName, guarding
|
||||
// against double registration (customElements.define throws on a duplicate). A
|
||||
// no-op in non-DOM contexts. Returns the tag name.
|
||||
export function registerSqlEditorElement(tagName = "datasette-sql-editor") {
|
||||
if (typeof customElements === "undefined") return tagName;
|
||||
if (!customElements.get(tagName)) {
|
||||
customElements.define(tagName, DatasetteSqlEditorElement);
|
||||
}
|
||||
return tagName;
|
||||
}
|
||||
|
||||
// Re-export the CodeMirror pieces callers need so plugin code shares this
|
||||
// module's single CM instance instead of bundling its own.
|
||||
export {
|
||||
|
|
@ -324,3 +615,16 @@ export {
|
|||
autocompletion,
|
||||
completionKeymap,
|
||||
};
|
||||
|
||||
// Auto-register the default <datasette-sql-editor> tag on import. Guarded so
|
||||
// importing this module in a non-DOM context (SSR/tests) or after another copy of
|
||||
// the module already claimed the tag is a harmless no-op. This gives template and
|
||||
// dogfood usage a zero-config element; plugins that want to compose the primitives
|
||||
// without the element simply don't touch the tag. Register a differently-named tag
|
||||
// with registerSqlEditorElement("my-tag").
|
||||
if (
|
||||
typeof customElements !== "undefined" &&
|
||||
!customElements.get("datasette-sql-editor")
|
||||
) {
|
||||
registerSqlEditorElement("datasette-sql-editor");
|
||||
}
|
||||
|
|
|
|||
157
demos/sql-editor-element.html
Normal file
157
demos/sql-editor-element.html
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<!DOCTYPE html>
|
||||
<!--
|
||||
Manual-QA demo for <datasette-sql-editor> (ticket 08).
|
||||
|
||||
This page is NOT served by Datasette by default. Launch a Datasette instance
|
||||
with this directory mounted at /demos so both the demo page and the ESM bundle
|
||||
are served same-origin (the schema fetch needs same-origin):
|
||||
|
||||
uv run python tests/fixtures.py fixtures.db # once, to build fixtures
|
||||
uv run datasette fixtures.db --static demos:demos/ -p 8001
|
||||
|
||||
Then open:
|
||||
|
||||
http://localhost:8001/demos/sql-editor-element.html
|
||||
|
||||
The editor imports /-/static/datasette-sql-editor.bundle.js (Datasette's built
|
||||
ESM module, which auto-registers the <datasette-sql-editor> tag on import) and
|
||||
fetches its schema from /fixtures/-/editor-schema.json.
|
||||
|
||||
QA checklist:
|
||||
[ ] Editor mounts; "ready" logs in the event panel.
|
||||
[ ] Typing "select st" / "facetable." offers ranked, typed column completions
|
||||
(schema fetch succeeded). Ctrl-Space forces the completion popup.
|
||||
[ ] Editing fires "input" events (origin: user).
|
||||
[ ] Shift-Enter / Mod-Enter fires a cancelable "submit"; with the checkbox
|
||||
UNchecked the form GET-submits ?sql=... (name=sql). Checked cancels it.
|
||||
[ ] The plain "Run SQL" button also submits the current editor text as sql=.
|
||||
[ ] Escape fires "editor-escape".
|
||||
[ ] Reset button restores the initial document (form reset).
|
||||
[ ] Format button reformats via window.sqlFormatter (loaded below).
|
||||
[ ] Read-only toggle disables editing.
|
||||
[ ] Point schema-url at a bad URL (edit source) -> editor still works,
|
||||
keyword-only completion, a console.warn appears.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title><datasette-sql-editor> demo</title>
|
||||
<!-- sql-formatter global (window.sqlFormatter) so format() works. -->
|
||||
<script src="/-/static/sql-formatter-2.3.3.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--datasette-sql-editor-font-size: 14px;
|
||||
}
|
||||
body {
|
||||
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
max-width: 820px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
datasette-sql-editor {
|
||||
display: block;
|
||||
}
|
||||
.cm-editor {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.75rem 0;
|
||||
align-items: center;
|
||||
}
|
||||
#log {
|
||||
background: #111;
|
||||
color: #b9f6ca;
|
||||
font: 12px/1.5 ui-monospace, Menlo, monospace;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
height: 200px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1><datasette-sql-editor> demo</h1>
|
||||
<p>
|
||||
Form-associated custom element against the live
|
||||
<code>fixtures</code> database. See the HTML comment at the top of this file
|
||||
for launch and QA instructions.
|
||||
</p>
|
||||
|
||||
<form id="f" action="/fixtures/-/query" method="get">
|
||||
<datasette-sql-editor
|
||||
id="ed"
|
||||
name="sql"
|
||||
database="fixtures"
|
||||
default-table="facetable"
|
||||
autofocus
|
||||
>
|
||||
select state, county, on_earth
|
||||
from facetable
|
||||
where on_earth = 1
|
||||
order by pk</datasette-sql-editor
|
||||
>
|
||||
<div class="toolbar">
|
||||
<button type="submit">Run SQL</button>
|
||||
<button type="button" id="format">Format</button>
|
||||
<button type="reset">Reset</button>
|
||||
<label
|
||||
><input type="checkbox" id="cancel" /> cancel submit event</label
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" id="ro" /> read-only</label
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<h3>Events</h3>
|
||||
<div id="log"></div>
|
||||
|
||||
<script type="module">
|
||||
// Importing the bundle auto-registers <datasette-sql-editor>.
|
||||
import "/-/static/datasette-sql-editor.bundle.js";
|
||||
|
||||
const ed = document.getElementById("ed");
|
||||
const log = document.getElementById("log");
|
||||
const line = (m) => {
|
||||
log.textContent += m + "\n";
|
||||
log.scrollTop = log.scrollHeight;
|
||||
};
|
||||
|
||||
["ready", "input", "submit", "editor-escape"].forEach((type) => {
|
||||
ed.addEventListener(type, (e) => {
|
||||
if (type === "submit" && document.getElementById("cancel").checked) {
|
||||
e.preventDefault();
|
||||
line("submit (CANCELED via preventDefault)");
|
||||
return;
|
||||
}
|
||||
line(
|
||||
type +
|
||||
(e.detail ? " " + JSON.stringify(e.detail) : "") +
|
||||
" | value=" +
|
||||
JSON.stringify(ed.value.replace(/\s+/g, " ").slice(0, 60)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("format").addEventListener("click", () => {
|
||||
ed.format();
|
||||
line("format() called");
|
||||
});
|
||||
document.getElementById("ro").addEventListener("change", (e) => {
|
||||
ed.readOnly = e.target.checked;
|
||||
line("readOnly = " + e.target.checked);
|
||||
});
|
||||
// view escape hatch smoke test
|
||||
line("view is EditorView? " + (ed.view && ed.view.constructor.name));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue