Wizard step 2: schema browser panel with datalist autocomplete

Adds a /wizard/schemas JSON endpoint and a live-filtered schema picker
panel on step 2. Clicking a row fills the schema input; the datalist
also powers browser autocomplete. MSSQL refetches when database or
linked_server qualifiers change. CSS fixes prevent picker tables and
two-col grid items from overflowing their containers.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-05-02 20:56:18 -04:00
parent ff19ae9b81
commit 546242e11a
3 changed files with 134 additions and 0 deletions

View File

@ -295,6 +295,29 @@ def wizard_step2(request: Request,
) )
@_router.get("/wizard/schemas")
def wizard_schemas(request: Request,
source_connection_id: int = Query(...)):
"""JSON list of schema names. Drivers that need scoping (e.g. MSSQL by
database) read those qualifiers from the querystring."""
conn = repo.get_connection(source_connection_id)
if conn is None:
raise HTTPException(404, f"connection id={source_connection_id} not found")
drv = _driver_for_conn(conn)
if drv is None:
raise HTTPException(500, "driver row missing for connection")
qp = dict(request.query_params)
qvals = {f.name: qp[f.name] for f in drv.browse_fields()
if f.name != "schema" and qp.get(f.name)}
try:
names = drv.list_schemas(conn, **qvals)
except (jrunner.JrunnerError, ValueError) as e:
return {"error": str(e), "schemas": []}
except Exception as e: # noqa: BLE001
return {"error": f"{type(e).__name__}: {e}", "schemas": []}
return {"schemas": names}
@_router.get("/wizard/columns", response_class=HTMLResponse) @_router.get("/wizard/columns", response_class=HTMLResponse)
def wizard_step3(request: Request, def wizard_step3(request: Request,
source_connection_id: int = Query(...), source_connection_id: int = Query(...),

View File

@ -206,6 +206,9 @@ form.inline { display: inline; }
grid-template-columns: 2fr 1fr; grid-template-columns: 2fr 1fr;
gap: 1rem; gap: 1rem;
} }
/* Grid items default to min-width:auto which is content-sized; let them
shrink so wide tables/inputs inside don't blow out the layout. */
.two-col > * { min-width: 0; }
@media (max-width: 900px) { @media (max-width: 900px) {
.two-col { grid-template-columns: 1fr; } .two-col { grid-template-columns: 1fr; }
} }
@ -261,11 +264,16 @@ label.field .help { grid-column: 2; color: var(--text-muted); font-size: 12px; }
.steps .step .num { font-weight: 700; margin-right: 0.4rem; } .steps .step .num { font-weight: 700; margin-right: 0.4rem; }
/* Radio/checkbox-in-row tables */ /* Radio/checkbox-in-row tables */
table.picker { table-layout: fixed; }
table.picker td.pick { width: 2.5rem; text-align: center; } table.picker td.pick { width: 2.5rem; text-align: center; }
table.picker input[type="radio"], table.picker input[type="radio"],
table.picker input[type="checkbox"] { margin: 0; } table.picker input[type="checkbox"] { margin: 0; }
table.picker tbody tr { cursor: pointer; } table.picker tbody tr { cursor: pointer; }
table.picker tbody tr:hover td { background: #1c2128; } table.picker tbody tr:hover td { background: #1c2128; }
/* Drop the global 14rem min-width for in-row text inputs so the picker
table can shrink to its container instead of pushing past it. */
table.picker input[type="text"] { min-width: 0; }
table.picker td { overflow: hidden; text-overflow: ellipsis; }
/* Flash messages */ /* Flash messages */
.flash { .flash {

View File

@ -22,10 +22,12 @@
<input type="text" name="{{ f.name }}" <input type="text" name="{{ f.name }}"
value="{{ qvals.get(f.name, '') }}" value="{{ qvals.get(f.name, '') }}"
{% if f.required %}required{% endif %} {% if f.required %}required{% endif %}
{% if f.name == 'schema' %}list="schema-options" autocomplete="off" id="schema-input"{% endif %}
placeholder="{{ f.default or '' }}"> placeholder="{{ f.default or '' }}">
{% if f.help %}<span class="help">{{ f.help }}</span>{% endif %} {% if f.help %}<span class="help">{{ f.help }}</span>{% endif %}
</label> </label>
{% endfor %} {% endfor %}
<datalist id="schema-options"></datalist>
<label class="field"> <label class="field">
<span>table (skip browse)</span> <span>table (skip browse)</span>
@ -64,11 +66,112 @@
} }
}); });
updateBtn(); updateBtn();
// Populate <datalist> + visible "Schemas" panel from the
// source DB. Refetch when scoping qualifiers change (mssql:
// database, linked_server).
var dlist = document.getElementById('schema-options');
var countTag = document.getElementById('schema-count');
var gridBody = document.getElementById('schema-grid-body');
var emptyTag = document.getElementById('schema-empty');
var filterInput = document.getElementById('schema-filter');
var dbInput = document.querySelector('input[name="database"]');
var lsInput = document.querySelector('input[name="linked_server"]');
var connId = {{ connection.id }};
var allSchemas = [];
var inFlight = 0;
function applySchemaFilter() {
var q = (filterInput.value || '').toLowerCase().trim();
var visible = 0;
Array.prototype.forEach.call(gridBody.children, function (tr) {
var n = tr.getAttribute('data-name') || '';
var show = !q || n.toLowerCase().indexOf(q) !== -1;
tr.style.display = show ? '' : 'none';
if (show) visible++;
});
countTag.textContent = q
? visible + ' of ' + allSchemas.length + ' shown'
: allSchemas.length + ' total';
}
function renderSchemas() {
dlist.innerHTML = '';
gridBody.innerHTML = '';
allSchemas.forEach(function (s) {
var o = document.createElement('option');
o.value = s;
dlist.appendChild(o);
var tr = document.createElement('tr');
tr.setAttribute('data-name', s);
tr.style.cursor = 'pointer';
var td = document.createElement('td');
td.className = 'mono';
td.textContent = s;
tr.appendChild(td);
tr.addEventListener('click', function () {
if (schemaInput) schemaInput.value = s;
});
gridBody.appendChild(tr);
});
emptyTag.style.display = allSchemas.length ? 'none' : '';
applySchemaFilter();
}
function loadSchemas() {
var qs = new URLSearchParams({ source_connection_id: connId });
if (dbInput && dbInput.value.trim()) qs.set('database', dbInput.value.trim());
if (lsInput && lsInput.value.trim()) qs.set('linked_server', lsInput.value.trim());
var token = ++inFlight;
countTag.textContent = 'loading…';
fetch('/wizard/schemas?' + qs.toString())
.then(function (r) { return r.json(); })
.then(function (j) {
if (token !== inFlight) return; // stale
allSchemas = j.schemas || [];
renderSchemas();
if (j.error) {
countTag.textContent = 'load failed: ' + j.error;
countTag.style.color = 'var(--danger)';
} else {
countTag.style.color = '';
}
})
.catch(function (e) {
if (token !== inFlight) return;
countTag.textContent = 'load failed: ' + e;
countTag.style.color = 'var(--danger)';
});
}
filterInput.addEventListener('input', applySchemaFilter);
if (dbInput) dbInput.addEventListener('change', loadSchemas);
if (lsInput) lsInput.addEventListener('change', loadSchemas);
loadSchemas();
})(); })();
</script> </script>
</div> </div>
</div> </div>
<div class="panel" id="schema-panel">
<header>
Schemas
<span class="subtitle" id="schema-count">loading…</span>
<span style="margin-left:auto">
<input type="text" id="schema-filter"
placeholder="filter (substring)"
autocomplete="off" spellcheck="false"
style="min-width:18rem;font-family:var(--mono);font-size:12px">
</span>
</header>
<div class="body tight" style="max-height:18rem;overflow:auto">
<table class="grid picker" id="schema-grid">
<tbody id="schema-grid-body"></tbody>
</table>
<div id="schema-empty" class="empty" style="display:none">no schemas</div>
</div>
</div>
{% if fetch_error %} {% if fetch_error %}
<div class="panel"> <div class="panel">
<header>Browse failed</header> <header>Browse failed</header>