mirror of
https://github.com/simonw/datasette.git
synced 2026-05-28 04:46:18 +02:00
Major redesign of create saved query UI
https://github.com/simonw/datasette/pull/2741#issuecomment-4548707129
This commit is contained in:
parent
70b23ff4a5
commit
eb7c25c57c
9 changed files with 705 additions and 172 deletions
|
|
@ -50,7 +50,7 @@ from .views.database import (
|
|||
ExecuteWriteView,
|
||||
TableCreateView,
|
||||
QueryView,
|
||||
QueryCreateView,
|
||||
QueryCreateAnalyzeView,
|
||||
QueryDeleteView,
|
||||
QueryDefinitionView,
|
||||
GlobalQueryListView,
|
||||
|
|
@ -2820,8 +2820,8 @@ class Datasette:
|
|||
r"/(?P<database>[^\/\.]+)/-/queries(\.(?P<format>json))?$",
|
||||
)
|
||||
add_route(
|
||||
QueryCreateView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries/-/create$",
|
||||
QueryCreateAnalyzeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/-/queries/analyze$",
|
||||
)
|
||||
add_route(
|
||||
QueryInsertView.as_view(self),
|
||||
|
|
|
|||
|
|
@ -1414,6 +1414,10 @@ svg.dropdown-menu-icon {
|
|||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.save-query {
|
||||
display: inline-block;
|
||||
margin-left: 0.45em;
|
||||
}
|
||||
|
||||
.blob-download {
|
||||
display: block;
|
||||
|
|
|
|||
111
datasette/templates/_execute_write_analysis_scripts.html
Normal file
111
datasette/templates/_execute_write_analysis_scripts.html
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<script>
|
||||
window.datasetteSqlAnalysis = (() => {
|
||||
if (
|
||||
window.datasetteSqlAnalysis &&
|
||||
window.datasetteSqlAnalysis.renderAnalysis
|
||||
) {
|
||||
return window.datasetteSqlAnalysis;
|
||||
}
|
||||
|
||||
function appendCodeCell(row, value, emptyText) {
|
||||
const cell = document.createElement("td");
|
||||
if (value) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = value;
|
||||
cell.appendChild(code);
|
||||
} else if (emptyText) {
|
||||
appendNotApplicable(cell);
|
||||
}
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function appendNotApplicable(cell) {
|
||||
const notApplicable = document.createElement("span");
|
||||
notApplicable.className = "execute-write-analysis-na";
|
||||
notApplicable.textContent = "n/a";
|
||||
cell.appendChild(notApplicable);
|
||||
}
|
||||
|
||||
function renderAnalysis(section, data) {
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
section.replaceChildren();
|
||||
if (data.has_sql === false) {
|
||||
section.hidden = true;
|
||||
return;
|
||||
}
|
||||
section.hidden = false;
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Query operations";
|
||||
section.appendChild(heading);
|
||||
|
||||
if (data.analysis_error) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "message-error";
|
||||
error.textContent = data.analysis_error;
|
||||
section.appendChild(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = data.analysis_rows || [];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent =
|
||||
"Analysis will show each affected table and required permission.";
|
||||
section.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "table-wrapper";
|
||||
const table = document.createElement("table");
|
||||
table.className = "execute-write-analysis";
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
[
|
||||
"Operation",
|
||||
"Database",
|
||||
"Table",
|
||||
"Required permission",
|
||||
"Allowed",
|
||||
].forEach((label) => {
|
||||
const th = document.createElement("th");
|
||||
th.scope = "col";
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach((analysisRow) => {
|
||||
const row = document.createElement("tr");
|
||||
appendCodeCell(row, analysisRow.operation);
|
||||
appendCodeCell(row, analysisRow.database);
|
||||
appendCodeCell(row, analysisRow.table);
|
||||
appendCodeCell(row, analysisRow.required_permission, "n/a");
|
||||
|
||||
const allowedCell = document.createElement("td");
|
||||
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
|
||||
const allowed = document.createElement("span");
|
||||
allowed.className = analysisRow.allowed
|
||||
? "execute-write-analysis-allowed"
|
||||
: "execute-write-analysis-denied";
|
||||
allowed.textContent = analysisRow.allowed ? "yes" : "no";
|
||||
allowedCell.appendChild(allowed);
|
||||
} else {
|
||||
appendNotApplicable(allowedCell);
|
||||
}
|
||||
row.appendChild(allowedCell);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrapper.appendChild(table);
|
||||
section.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return { renderAnalysis };
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -34,4 +34,8 @@
|
|||
color: #b00020;
|
||||
font-weight: 700;
|
||||
}
|
||||
.execute-write-analysis-na {
|
||||
color: #687386;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -215,9 +215,10 @@ window.datasetteSqlParameters = (() => {
|
|||
if (!form) {
|
||||
return null;
|
||||
}
|
||||
const shouldRenderParameters = options.renderParameters !== false;
|
||||
const section =
|
||||
options.section || form.querySelector("[data-sql-parameters-section]");
|
||||
if (!section) {
|
||||
if (shouldRenderParameters && !section) {
|
||||
return null;
|
||||
}
|
||||
const manager = {
|
||||
|
|
@ -225,12 +226,16 @@ window.datasetteSqlParameters = (() => {
|
|||
section,
|
||||
allowExpand:
|
||||
options.allowExpand === undefined
|
||||
? section.dataset.allowExpand === "1"
|
||||
? section
|
||||
? section.dataset.allowExpand === "1"
|
||||
: false
|
||||
: options.allowExpand,
|
||||
parameterState: new Map(),
|
||||
};
|
||||
bindParameterControls(manager);
|
||||
syncParameterState(manager);
|
||||
if (section) {
|
||||
bindParameterControls(manager);
|
||||
syncParameterState(manager);
|
||||
}
|
||||
|
||||
const url = options.url || form.dataset.parametersUrl;
|
||||
let refreshTimer = null;
|
||||
|
|
@ -254,7 +259,9 @@ window.datasetteSqlParameters = (() => {
|
|||
if (!response.ok) {
|
||||
throw new Error((data.errors || [response.statusText]).join("; "));
|
||||
}
|
||||
renderParameters(manager, data.parameters || []);
|
||||
if (shouldRenderParameters) {
|
||||
renderParameters(manager, data.parameters || []);
|
||||
}
|
||||
if (options.onData) {
|
||||
options.onData(data, manager);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ if (executeWriteSqlInput && !executeWriteSqlInput.value) {
|
|||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
|
|
@ -140,101 +141,18 @@ window.addEventListener("DOMContentLoaded", () => {
|
|||
? form.querySelector("[data-execute-write-submit]")
|
||||
: null;
|
||||
|
||||
function appendCodeCell(row, value) {
|
||||
const cell = document.createElement("td");
|
||||
if (value) {
|
||||
const code = document.createElement("code");
|
||||
code.textContent = value;
|
||||
cell.appendChild(code);
|
||||
}
|
||||
row.appendChild(cell);
|
||||
}
|
||||
|
||||
function renderExecuteWriteAnalysis(data) {
|
||||
if (!analysisSection) {
|
||||
return;
|
||||
}
|
||||
analysisSection.replaceChildren();
|
||||
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = "Query operations";
|
||||
analysisSection.appendChild(heading);
|
||||
|
||||
if (data.analysis_error) {
|
||||
const error = document.createElement("p");
|
||||
error.className = "message-error";
|
||||
error.textContent = data.analysis_error;
|
||||
analysisSection.appendChild(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = data.analysis_rows || [];
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.textContent =
|
||||
"Analysis will show each affected table and required permission.";
|
||||
analysisSection.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "table-wrapper";
|
||||
const table = document.createElement("table");
|
||||
table.className = "execute-write-analysis";
|
||||
const thead = document.createElement("thead");
|
||||
const headerRow = document.createElement("tr");
|
||||
[
|
||||
"Operation",
|
||||
"Database",
|
||||
"Table",
|
||||
"Required permission",
|
||||
"Allowed",
|
||||
].forEach((label) => {
|
||||
const th = document.createElement("th");
|
||||
th.scope = "col";
|
||||
th.textContent = label;
|
||||
headerRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headerRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
rows.forEach((analysisRow) => {
|
||||
const row = document.createElement("tr");
|
||||
appendCodeCell(row, analysisRow.operation);
|
||||
appendCodeCell(row, analysisRow.database);
|
||||
appendCodeCell(row, analysisRow.table);
|
||||
appendCodeCell(row, analysisRow.required_permission);
|
||||
|
||||
const allowedCell = document.createElement("td");
|
||||
if (analysisRow.allowed !== null && analysisRow.allowed !== undefined) {
|
||||
const allowed = document.createElement("span");
|
||||
allowed.className = analysisRow.allowed
|
||||
? "execute-write-analysis-allowed"
|
||||
: "execute-write-analysis-denied";
|
||||
allowed.textContent = analysisRow.allowed ? "yes" : "no";
|
||||
allowedCell.appendChild(allowed);
|
||||
}
|
||||
row.appendChild(allowedCell);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrapper.appendChild(table);
|
||||
analysisSection.appendChild(wrapper);
|
||||
}
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
allowExpand: true,
|
||||
onData(data) {
|
||||
renderExecuteWriteAnalysis(data);
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.execute_disabled;
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
renderExecuteWriteAnalysis({
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,136 @@
|
|||
{{- super() -}}
|
||||
{% include "_codemirror.html" %}
|
||||
{% include "_execute_write_analysis_styles.html" %}
|
||||
<style>
|
||||
.query-create-page {
|
||||
max-width: 64rem;
|
||||
}
|
||||
.query-create-form {
|
||||
--query-create-label-width: clamp(7rem, 18vw, 10rem);
|
||||
--query-create-column-gap: 0.8rem;
|
||||
--query-create-control-width: minmax(16rem, 1fr);
|
||||
}
|
||||
.query-create-fields {
|
||||
margin: 0 0 0.85rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.query-create-field {
|
||||
align-items: start;
|
||||
column-gap: var(--query-create-column-gap);
|
||||
display: grid;
|
||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
||||
margin: 0 0 0.65rem;
|
||||
}
|
||||
.query-create-field label {
|
||||
padding-top: 0.55rem;
|
||||
width: auto;
|
||||
}
|
||||
.query-create-field input[type=text],
|
||||
.query-create-field textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
form.sql .query-create-field textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-url-control {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
grid-template-columns: max-content minmax(12rem, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-url-prefix {
|
||||
color: #4f5b6d;
|
||||
font-family: var(--font-monospace, monospace);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.query-create-url-control input[type=text] {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.query-create-field textarea {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
display: block;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 1em;
|
||||
min-height: 5rem;
|
||||
padding: 9px 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
form.sql .query-create-sql {
|
||||
column-gap: var(--query-create-column-gap);
|
||||
display: grid;
|
||||
grid-template-columns: var(--query-create-label-width) var(--query-create-control-width);
|
||||
margin: 0.9rem 0 0.75rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.query-create-sql .cm-editor,
|
||||
form.sql .query-create-sql textarea#sql-editor {
|
||||
grid-column: 2;
|
||||
width: 100%;
|
||||
}
|
||||
.query-create-options {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem 1.4rem;
|
||||
margin: 0 0 0.9rem calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
||||
max-width: calc(52rem - var(--query-create-label-width) - var(--query-create-column-gap));
|
||||
}
|
||||
.query-create-options label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
width: auto;
|
||||
}
|
||||
.query-create-options input[type=checkbox] {
|
||||
margin: 0;
|
||||
}
|
||||
.query-create-option-note {
|
||||
color: #4f5b6d;
|
||||
flex-basis: 100%;
|
||||
font-size: 0.82rem;
|
||||
margin: -0.45rem 0 0;
|
||||
}
|
||||
.query-create-action {
|
||||
margin: 0.35rem 0 1rem;
|
||||
}
|
||||
.query-create-analysis {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
.query-create-submit {
|
||||
margin-left: calc(var(--query-create-label-width) + var(--query-create-column-gap));
|
||||
margin-bottom: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.query-create-form {
|
||||
--query-create-label-width: 1fr;
|
||||
--query-create-column-gap: 0;
|
||||
}
|
||||
.query-create-field {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 0.25rem;
|
||||
}
|
||||
.query-create-field label {
|
||||
padding-top: 0;
|
||||
}
|
||||
form.sql .query-create-sql {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.query-create-sql .cm-editor,
|
||||
form.sql .query-create-sql textarea#sql-editor {
|
||||
grid-column: 1;
|
||||
}
|
||||
.query-create-options,
|
||||
.query-create-submit {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body_class %}query-create db-{{ database|to_css_class }}{% endblock %}
|
||||
|
|
@ -16,56 +146,140 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<div class="query-create-page">
|
||||
|
||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">Create query</h1>
|
||||
|
||||
<form class="sql core" action="{{ urls.database(database) }}/-/queries/insert" method="post">
|
||||
<p><label for="query-name">Name</label> <input id="query-name" name="name" type="text"></p>
|
||||
<p><label for="query-title">Title</label> <input id="query-title" name="title" type="text"></p>
|
||||
<p><label for="query-description">Description</label><br><textarea id="query-description" name="description" rows="3"></textarea></p>
|
||||
<p>
|
||||
<label><input type="radio" name="mode" value="read-only" checked> Read-only</label>
|
||||
<label><input type="radio" name="mode" value="writable"> Writable</label>
|
||||
<form class="sql core query-create-form" action="{{ urls.database(database) }}/-/queries/insert" method="post" data-analyze-url="{{ urls.database(database) }}/-/queries/analyze">
|
||||
<div class="query-create-fields">
|
||||
<p class="query-create-field"><label for="query-title">Title</label> <input id="query-title" name="title" type="text" value="{{ title or "" }}"></p>
|
||||
<p class="query-create-field"><label for="query-url-slug">URL</label> <span class="query-create-url-control"><span class="query-create-url-prefix">{{ urls.database(database) }}/</span><input id="query-url-slug" name="name" type="text" value="{{ name or "" }}"></span></p>
|
||||
<p class="query-create-field"><label for="query-description">Description</label> <textarea id="query-description" name="description" rows="3">{{ description or "" }}</textarea></p>
|
||||
</div>
|
||||
|
||||
<p class="query-create-sql sql-editor"><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
|
||||
<p class="query-create-options">
|
||||
<input type="hidden" name="is_private" value="0">
|
||||
<label><input type="checkbox" name="is_private" value="1"{% if is_private %} checked{% endif %}> Private</label>
|
||||
<label><input type="checkbox" data-query-create-writable{% if analysis_is_write %} checked{% endif %} disabled> Writable</label>
|
||||
<span class="query-create-option-note">Queries marked private can only be seen by you, their creator.</span>
|
||||
</p>
|
||||
<p><textarea id="sql-editor" name="sql"{% if sql %} style="height: {{ sql.split("\n")|length + 2 }}em"{% endif %}>{{ sql }}</textarea></p>
|
||||
<p><label for="query-parameters">Parameters</label> <input id="query-parameters" name="parameters" type="text" value="{{ parameter_names|join(', ') }}"></p>
|
||||
<p><label><input type="checkbox" name="is_private" value="1" checked> Private</label></p>
|
||||
{% if sql and analysis_is_write %}
|
||||
<p><a href="{{ urls.database(database) }}/-/execute-write?{{ {'sql': sql}|urlencode|safe }}">Execute write SQL</a></p>
|
||||
<p class="query-create-action"><a href="{{ urls.database(database) }}/-/execute-write?{{ {'sql': sql}|urlencode|safe }}">Execute write SQL</a></p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
<p class="query-create-submit"><input type="submit" value="Save query" data-query-create-submit{% if save_disabled %} disabled{% endif %}></p>
|
||||
|
||||
<p><input type="submit" value="Save query"{% if save_disabled %} disabled{% endif %}></p>
|
||||
<div class="query-create-analysis" id="query-create-analysis-section"{% if not has_sql %} hidden{% endif %}>
|
||||
{% if has_sql %}
|
||||
<h2>Query operations</h2>
|
||||
{% if analysis_error %}
|
||||
<p class="message-error">{{ analysis_error }}</p>
|
||||
{% elif analysis_rows %}
|
||||
<div class="table-wrapper"><table class="execute-write-analysis">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Operation</th>
|
||||
<th scope="col">Database</th>
|
||||
<th scope="col">Table</th>
|
||||
<th scope="col">Required permission</th>
|
||||
<th scope="col">Allowed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in analysis_rows %}
|
||||
<tr>
|
||||
<td><code>{{ row.operation }}</code></td>
|
||||
<td><code>{{ row.database }}</code></td>
|
||||
<td><code>{{ row.table }}</code></td>
|
||||
<td>{% if row.required_permission %}<code>{{ row.required_permission }}</code>{% else %}<span class="execute-write-analysis-na">n/a</span>{% endif %}</td>
|
||||
<td>{% if row.allowed is none %}<span class="execute-write-analysis-na">n/a</span>{% elif row.allowed %}<span class="execute-write-analysis-allowed">yes</span>{% else %}<span class="execute-write-analysis-denied">no</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% else %}
|
||||
<p>Analysis will show each affected table and required permission.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "_codemirror_foot.html" %}
|
||||
{% include "_sql_parameter_scripts.html" %}
|
||||
{% include "_execute_write_analysis_scripts.html" %}
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const titleInput = document.querySelector("#query-title");
|
||||
const urlInput = document.querySelector("#query-url-slug");
|
||||
let urlEdited = Boolean(urlInput && urlInput.value);
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
if (titleInput && urlInput) {
|
||||
titleInput.addEventListener("input", () => {
|
||||
if (!urlEdited) {
|
||||
urlInput.value = slugify(titleInput.value);
|
||||
}
|
||||
});
|
||||
urlInput.addEventListener("input", () => {
|
||||
urlEdited = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const form = document.querySelector("form.sql.core");
|
||||
const analysisSection = document.querySelector("#query-create-analysis-section");
|
||||
const submitButton = form
|
||||
? form.querySelector("[data-query-create-submit]")
|
||||
: null;
|
||||
const writableCheckbox = form
|
||||
? form.querySelector("[data-query-create-writable]")
|
||||
: null;
|
||||
|
||||
window.datasetteSqlParameters.setupSqlParameterRefresh({
|
||||
form,
|
||||
url: form.dataset.analyzeUrl,
|
||||
renderParameters: false,
|
||||
onData(data) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, data);
|
||||
if (submitButton) {
|
||||
submitButton.disabled = data.save_disabled;
|
||||
}
|
||||
if (writableCheckbox) {
|
||||
writableCheckbox.checked = data.analysis_is_write;
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
window.datasetteSqlAnalysis.renderAnalysis(analysisSection, {
|
||||
analysis_error: error.message,
|
||||
analysis_rows: [],
|
||||
});
|
||||
if (submitButton) {
|
||||
submitButton.disabled = true;
|
||||
}
|
||||
if (writableCheckbox) {
|
||||
writableCheckbox.checked = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -551,6 +551,17 @@ def _wants_json(request, is_json, data):
|
|||
)
|
||||
|
||||
|
||||
def _query_create_form_error_message(message):
|
||||
return {
|
||||
"Query name is required": "URL is required",
|
||||
"Invalid query name": "Invalid URL",
|
||||
"Query name conflicts with a table or view": (
|
||||
"URL conflicts with an existing table or view"
|
||||
),
|
||||
"Query already exists": "A query already exists at that URL",
|
||||
}.get(message, message)
|
||||
|
||||
|
||||
async def _json_or_form_payload(request):
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if content_type.startswith("application/json"):
|
||||
|
|
@ -731,6 +742,54 @@ async def _execute_write_analysis_data(datasette, db, sql, actor):
|
|||
}
|
||||
|
||||
|
||||
async def _query_create_analysis_data(datasette, db, sql, actor):
|
||||
has_sql = bool(sql and sql.strip())
|
||||
parameter_names = []
|
||||
analysis_rows = []
|
||||
analysis_error = None
|
||||
if has_sql:
|
||||
try:
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in parameter_names}
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
analysis_rows = await _analysis_rows_with_permissions(
|
||||
datasette, analysis, actor
|
||||
)
|
||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||
analysis_error = getattr(ex, "message", str(ex))
|
||||
return {
|
||||
"ok": analysis_error is None,
|
||||
"parameters": parameter_names,
|
||||
"analysis_error": analysis_error,
|
||||
"analysis_rows": analysis_rows,
|
||||
"has_sql": has_sql,
|
||||
"analysis_is_write": bool(
|
||||
analysis_rows and any(row["required_permission"] for row in analysis_rows)
|
||||
),
|
||||
"save_disabled": bool(
|
||||
(not has_sql)
|
||||
or analysis_error
|
||||
or any(row["allowed"] is False for row in analysis_rows)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def _query_create_form_context(
|
||||
datasette, request, db, *, sql="", name="", title="", description="", is_private=True
|
||||
):
|
||||
analysis_data = await _query_create_analysis_data(datasette, db, sql, request.actor)
|
||||
return {
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"sql": sql,
|
||||
"name": name,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"is_private": is_private,
|
||||
**analysis_data,
|
||||
}
|
||||
|
||||
|
||||
async def _inserted_row_url(datasette, db, analysis, cursor):
|
||||
if cursor.rowcount != 1:
|
||||
return None
|
||||
|
|
@ -1307,6 +1366,35 @@ class QueryCreateView(BaseView):
|
|||
name = "query-create"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _render_form(
|
||||
self,
|
||||
request,
|
||||
db,
|
||||
*,
|
||||
sql="",
|
||||
name="",
|
||||
title="",
|
||||
description="",
|
||||
is_private=True,
|
||||
status=200,
|
||||
):
|
||||
response = await self.render(
|
||||
["query_create.html"],
|
||||
request,
|
||||
await _query_create_form_context(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
sql=sql,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
is_private=is_private,
|
||||
),
|
||||
)
|
||||
response.status = status
|
||||
return response
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
await self.ds.ensure_permission(
|
||||
|
|
@ -1320,46 +1408,61 @@ class QueryCreateView(BaseView):
|
|||
actor=request.actor,
|
||||
)
|
||||
|
||||
sql = request.args.get("sql") or ""
|
||||
analysis_error = None
|
||||
analysis_rows = []
|
||||
parameter_names = []
|
||||
if sql:
|
||||
try:
|
||||
parameter_names = _derived_query_parameters(sql)
|
||||
params = {parameter: "" for parameter in parameter_names}
|
||||
analysis = await db.analyze_sql(sql, params)
|
||||
analysis_rows = await _analysis_rows_with_permissions(
|
||||
self.ds, analysis, request.actor
|
||||
)
|
||||
except (QueryValidationError, sqlite3.DatabaseError) as ex:
|
||||
analysis_error = getattr(ex, "message", str(ex))
|
||||
return await self._render_form(request, db, sql=request.args.get("sql") or "")
|
||||
|
||||
return await self.render(
|
||||
["query_create.html"],
|
||||
request,
|
||||
{
|
||||
"database": db.name,
|
||||
"database_color": db.color,
|
||||
"sql": sql,
|
||||
"parameter_names": parameter_names,
|
||||
"analysis_error": analysis_error,
|
||||
"analysis_rows": analysis_rows,
|
||||
"analysis_is_write": bool(
|
||||
analysis_rows
|
||||
and any(row["required_permission"] for row in analysis_rows)
|
||||
),
|
||||
"save_disabled": bool(
|
||||
analysis_error
|
||||
or any(row["allowed"] is False for row in analysis_rows)
|
||||
),
|
||||
},
|
||||
|
||||
class QueryCreateAnalyzeView(BaseView):
|
||||
name = "query-create-analyze"
|
||||
has_json_alternate = False
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need execute-sql"], 403))
|
||||
if not await self.ds.allowed(
|
||||
action="insert-query",
|
||||
resource=DatabaseResource(db.name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return _block_framing(_error(["Permission denied: need insert-query"], 403))
|
||||
|
||||
invalid_keys = set(request.args) - {"sql"}
|
||||
if invalid_keys:
|
||||
return _block_framing(
|
||||
_error(
|
||||
["Invalid keys: {}".format(", ".join(sorted(invalid_keys)))],
|
||||
400,
|
||||
)
|
||||
)
|
||||
sql = request.args.get("sql") or ""
|
||||
return _block_framing(
|
||||
Response.json(
|
||||
await _query_create_analysis_data(self.ds, db, sql, request.actor)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class QueryInsertView(BaseView):
|
||||
class QueryInsertView(QueryCreateView):
|
||||
name = "query-insert"
|
||||
|
||||
async def _error_response(self, request, db, query_data, message, status):
|
||||
message = _query_create_form_error_message(message)
|
||||
self.ds.add_message(request, message, self.ds.ERROR)
|
||||
return await self._render_form(
|
||||
request,
|
||||
db,
|
||||
sql=query_data.get("sql") or "",
|
||||
name=query_data.get("name") or "",
|
||||
title=query_data.get("title") or "",
|
||||
description=query_data.get("description") or "",
|
||||
is_private=_as_bool(query_data.get("is_private", True)),
|
||||
status=status,
|
||||
)
|
||||
|
||||
async def post(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
if not await self.ds.allowed(
|
||||
|
|
@ -1375,6 +1478,8 @@ class QueryInsertView(BaseView):
|
|||
):
|
||||
return _error(["Permission denied: need insert-query"], 403)
|
||||
|
||||
is_json = False
|
||||
query_data = {}
|
||||
try:
|
||||
data, is_json = await _json_or_form_payload(request)
|
||||
if not isinstance(data, dict):
|
||||
|
|
@ -1384,6 +1489,10 @@ class QueryInsertView(BaseView):
|
|||
raise QueryValidationError("JSON must contain a query dictionary")
|
||||
prepared = await _prepare_query_create(self.ds, request, db, query_data)
|
||||
except QueryValidationError as ex:
|
||||
if not is_json and isinstance(query_data, dict):
|
||||
return await self._error_response(
|
||||
request, db, query_data, ex.message, ex.status
|
||||
)
|
||||
return _error([ex.message], ex.status)
|
||||
|
||||
prepared.pop("analysis")
|
||||
|
|
@ -1391,6 +1500,8 @@ class QueryInsertView(BaseView):
|
|||
try:
|
||||
await self.ds.add_query(db.name, name, replace=False, **prepared)
|
||||
except sqlite3.IntegrityError as ex:
|
||||
if not is_json and isinstance(query_data, dict):
|
||||
return await self._error_response(request, db, query_data, str(ex), 400)
|
||||
return _error([str(ex)], 400)
|
||||
|
||||
query = await self.ds.get_query(db.name, name)
|
||||
|
|
@ -1896,7 +2007,7 @@ class QueryView(View):
|
|||
):
|
||||
save_query_url = (
|
||||
datasette.urls.database(database)
|
||||
+ "/-/queries/-/create?"
|
||||
+ "/-/queries/insert?"
|
||||
+ urlencode({"sql": sql})
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -986,6 +986,14 @@ async def test_create_query_ui_and_arbitrary_sql_save_link():
|
|||
await ds.invoke_startup()
|
||||
|
||||
create_response = await ds.client.get(
|
||||
"/data/-/queries/insert?sql=select+*+from+dogs",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
blank_create_response = await ds.client.get(
|
||||
"/data/-/queries/insert",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
old_create_response = await ds.client.get(
|
||||
"/data/-/queries/-/create?sql=select+*+from+dogs",
|
||||
actor={"id": "root"},
|
||||
)
|
||||
|
|
@ -996,16 +1004,171 @@ async def test_create_query_ui_and_arbitrary_sql_save_link():
|
|||
|
||||
assert create_response.status_code == 200
|
||||
assert "Create query" in create_response.text
|
||||
assert "Read-only" in create_response.text
|
||||
assert "Writable" in create_response.text
|
||||
assert 'type="radio"' not in create_response.text
|
||||
assert 'name="parameters"' not in create_response.text
|
||||
assert 'id="query-parameters"' not in create_response.text
|
||||
assert 'class="query-create-field"' in create_response.text
|
||||
assert '<label for="query-name">Name</label>' not in create_response.text
|
||||
assert '<label for="query-title">Title</label>' in create_response.text
|
||||
assert '<label for="query-url-slug">URL</label>' in create_response.text
|
||||
assert '<span class="query-create-url-prefix">/data/</span>' in create_response.text
|
||||
assert (
|
||||
'<input id="query-url-slug" name="name" type="text" value="">'
|
||||
in create_response.text
|
||||
)
|
||||
assert 'function slugify(value)' in create_response.text
|
||||
assert 'data-analyze-url="/data/-/queries/analyze"' in create_response.text
|
||||
assert "setupSqlParameterRefresh" in create_response.text
|
||||
assert "renderParameters: false" in create_response.text
|
||||
assert "datasetteSqlAnalysis.renderAnalysis" in create_response.text
|
||||
assert "data-query-create-submit" in create_response.text
|
||||
assert "data-query-create-writable" in create_response.text
|
||||
assert (
|
||||
"Queries marked private can only be seen by you, their creator."
|
||||
in create_response.text
|
||||
)
|
||||
assert "<h2>Query operations</h2>" in create_response.text
|
||||
assert '<table class="execute-write-analysis">' in create_response.text
|
||||
assert '<th scope="col">Required permission</th>' in create_response.text
|
||||
assert '<th scope="col">Source</th>' not in create_response.text
|
||||
assert "<td><code>read</code></td>" in create_response.text
|
||||
assert (
|
||||
create_response.text.count(
|
||||
'<td><span class="execute-write-analysis-na">n/a</span></td>'
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert create_response.text.index('value="Save query"') < create_response.text.index(
|
||||
"<h2>Query operations</h2>"
|
||||
)
|
||||
assert blank_create_response.status_code == 200
|
||||
assert (
|
||||
'<div class="query-create-analysis" id="query-create-analysis-section" hidden>'
|
||||
in blank_create_response.text
|
||||
)
|
||||
assert "<h2>Query operations</h2>" not in blank_create_response.text
|
||||
assert (
|
||||
"<p>Analysis will show each affected table and required permission.</p>"
|
||||
not in blank_create_response.text
|
||||
)
|
||||
assert query_response.status_code == 200
|
||||
assert "Save query" in query_response.text
|
||||
assert "/data/-/queries/-/create?sql=select+%2A+from+dogs" in query_response.text
|
||||
assert "Save this query" in query_response.text
|
||||
assert "/data/-/queries/insert?sql=select+%2A+from+dogs" in query_response.text
|
||||
assert old_create_response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_query_analyze_endpoint_uses_sql_only():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_create_analyze", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
response = await ds.client.get(
|
||||
"/data/-/queries/analyze",
|
||||
actor={"id": "root"},
|
||||
params={"sql": "select * from dogs where name = :name"},
|
||||
)
|
||||
write_response = await ds.client.get(
|
||||
"/data/-/queries/analyze",
|
||||
actor={"id": "root"},
|
||||
params={"sql": "insert into dogs (name) values (:name)"},
|
||||
)
|
||||
blank_response = await ds.client.get(
|
||||
"/data/-/queries/analyze",
|
||||
actor={"id": "root"},
|
||||
params={"sql": ""},
|
||||
)
|
||||
old_analyze_response = await ds.client.get(
|
||||
"/data/-/queries/-/create/analyze",
|
||||
actor={"id": "root"},
|
||||
params={"sql": "select * from dogs"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["parameters"] == ["name"]
|
||||
assert data["analysis_error"] is None
|
||||
assert data["has_sql"] is True
|
||||
assert data["analysis_is_write"] is False
|
||||
assert data["save_disabled"] is False
|
||||
assert data["analysis_rows"] == [
|
||||
{
|
||||
"operation": "read",
|
||||
"database": "data",
|
||||
"table": "dogs",
|
||||
"required_permission": "",
|
||||
"source": None,
|
||||
"allowed": None,
|
||||
}
|
||||
]
|
||||
|
||||
assert write_response.status_code == 200
|
||||
write_data = write_response.json()
|
||||
assert write_data["parameters"] == ["name"]
|
||||
assert write_data["has_sql"] is True
|
||||
assert write_data["analysis_is_write"] is True
|
||||
assert write_data["save_disabled"] is False
|
||||
assert write_data["analysis_rows"][0]["operation"] == "insert"
|
||||
|
||||
assert blank_response.status_code == 200
|
||||
blank_data = blank_response.json()
|
||||
assert blank_data["has_sql"] is False
|
||||
assert blank_data["parameters"] == []
|
||||
assert blank_data["analysis_rows"] == []
|
||||
assert blank_data["save_disabled"] is True
|
||||
assert old_analyze_response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_query_form_error_redisplays_form_with_values():
|
||||
ds = Datasette(memory=True, default_deny=True)
|
||||
ds.root_enabled = True
|
||||
db = ds.add_memory_database("query_create_form_error", name="data")
|
||||
await db.execute_write("create table dogs (id integer primary key, name text)")
|
||||
await ds.invoke_startup()
|
||||
|
||||
response = await ds.client.post(
|
||||
"/data/-/queries/insert",
|
||||
actor={"id": "root"},
|
||||
data={
|
||||
"name": "dogs",
|
||||
"title": "Dog lookup",
|
||||
"description": "Find dogs by name",
|
||||
"sql": "select * from dogs where name = :name",
|
||||
"is_private": "1",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert "URL conflicts with an existing table or view" in response.text
|
||||
assert "Query name conflicts with a table or view" not in response.text
|
||||
assert '{"ok": false' not in response.text
|
||||
assert 'value="Dog lookup"' in response.text
|
||||
assert 'value="dogs"' in response.text
|
||||
assert ">Find dogs by name</textarea>" in response.text
|
||||
assert "select * from dogs where name = :name" in response.text
|
||||
assert 'name="is_private" value="1" checked' in response.text
|
||||
|
||||
public_response = await ds.client.post(
|
||||
"/data/-/queries/insert",
|
||||
actor={"id": "root"},
|
||||
data={
|
||||
"name": "dogs",
|
||||
"title": "Public dog lookup",
|
||||
"description": "Keep this public setting",
|
||||
"sql": "select * from dogs",
|
||||
"is_private": "0",
|
||||
},
|
||||
)
|
||||
|
||||
assert public_response.status_code == 400
|
||||
assert 'name="is_private" value="1" checked' not in public_response.text
|
||||
assert 'name="is_private" value="0"' in public_response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1046,6 +1209,7 @@ async def test_execute_write_get_prepopulates_without_executing():
|
|||
assert 'data-analyze-url="/data/-/execute-write/analyze"' in response.text
|
||||
assert 'addEventListener("paste"' in response.text
|
||||
assert "setupSqlParameterRefresh" in response.text
|
||||
assert "datasetteSqlAnalysis.renderAnalysis" in response.text
|
||||
assert '<table class="execute-write-analysis">' in response.text
|
||||
assert '<th scope="col">Required permission</th>' in response.text
|
||||
assert "<td><code>insert</code></td>" in response.text
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue