mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 05:26:59 +02:00
Insert/edit/delete UI in Datasette core, plus makeColumnField() plugin hook
PR #2781 - A new `/db/table/-/autocomplete?q=term` JSON API for fast autocomplete search against foreign key tables - it searches against their label column or their primary key and switches to just a prefix search against the first primary key (for speed) if the label column check takes more than 500ms. A new `/-/debug/autocomplete` page lets you try this out. - A `<datasette-autocomplete>` Web Component that uses that API. - Table pages now get an insert button above the table, and little edit and delete icons next to each row. All three trigger custom modal dialogs. The edit/insert dialog is a full form - the delete one is just confirmation. - A new `/<database>/<table>/-/fragment?_row=` endpoint which returns a rendered fragment of HTML for the specified row. This is used by the insert/edit code to partially update the table to reflect those changes. Uses a new `data-row="{{ row.row_path }}"` attribute on the `<tr>` to enable the replacement. - A new default column type called `textarea` which users can use to specify a multi-line textarea for a column - A new JavaScript plugin hook, [makeColumnField()](3f7d389caf/docs/javascript_plugins.rst (makecolumnfieldcontext)), which plugins can use to add custom form fields to the edit form. Datasette [uses this itself](3f7d389caf/datasette/static/table.js (L1181-L1209)) for the JSON field to add client-side JSON validation. I iterated a *lot* on this one, including spinning up a `datasette-prosemirror` plugin and a branch of `datasette-files` to fully exercise it. Closes #2780 Video demo: https://github.com/user-attachments/assets/2c18b8a4-975f-4c7b-9573-ec6040fe8223
This commit is contained in:
commit
f1af216852
35 changed files with 6006 additions and 58 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
|||
build-metadata.json
|
||||
datasets.json
|
||||
|
||||
.playwright-mcp
|
||||
|
||||
scratchpad
|
||||
|
||||
.vscode
|
||||
|
|
@ -131,4 +133,4 @@ tests/*.dylib
|
|||
tests/*.so
|
||||
tests/*.dll
|
||||
|
||||
.idea
|
||||
.idea
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ from .views.index import IndexView
|
|||
from .views.special import (
|
||||
JsonDataView,
|
||||
PatternPortfolioView,
|
||||
AutocompleteDebugView,
|
||||
AuthTokenView,
|
||||
ApiExplorerView,
|
||||
CreateTokenView,
|
||||
|
|
@ -82,10 +83,12 @@ from .views.special import (
|
|||
TableSchemaView,
|
||||
)
|
||||
from .views.table import (
|
||||
TableAutocompleteView,
|
||||
TableInsertView,
|
||||
TableUpsertView,
|
||||
TableSetColumnTypeView,
|
||||
TableDropView,
|
||||
TableFragmentView,
|
||||
table_view,
|
||||
)
|
||||
from .views.row import RowView, RowDeleteView, RowUpdateView
|
||||
|
|
@ -2016,6 +2019,11 @@ class Datasette:
|
|||
|
||||
other_table = fk["other_table"]
|
||||
other_column = fk["other_column"]
|
||||
if other_column is None:
|
||||
other_pks = await db.primary_keys(other_table)
|
||||
if len(other_pks) != 1:
|
||||
return {}
|
||||
other_column = other_pks[0]
|
||||
visible, _ = await self.check_visibility(
|
||||
actor,
|
||||
action="view-table",
|
||||
|
|
@ -2312,6 +2320,8 @@ class Datasette:
|
|||
and "ds_actor" in request.cookies
|
||||
and request.actor,
|
||||
"app_css_hash": self.app_css_hash(),
|
||||
"edit_tools_js_hash": self.static_hash("edit-tools.js"),
|
||||
"table_js_hash": self.static_hash("table.js"),
|
||||
"zip": zip,
|
||||
"body_scripts": body_scripts,
|
||||
"format_bytes": format_bytes,
|
||||
|
|
@ -2536,6 +2546,10 @@ class Datasette:
|
|||
wrap_view(PatternPortfolioView, self),
|
||||
r"/-/patterns$",
|
||||
)
|
||||
add_route(
|
||||
AutocompleteDebugView.as_view(self),
|
||||
r"/-/debug/autocomplete$",
|
||||
)
|
||||
add_route(
|
||||
wrap_view(database_download, self),
|
||||
r"/(?P<database>[^\/\.]+)\.db$",
|
||||
|
|
@ -2613,6 +2627,14 @@ class Datasette:
|
|||
TableSetColumnTypeView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/set-column-type$",
|
||||
)
|
||||
add_route(
|
||||
TableFragmentView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/fragment$",
|
||||
)
|
||||
add_route(
|
||||
TableAutocompleteView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/autocomplete$",
|
||||
)
|
||||
add_route(
|
||||
TableDropView.as_view(self),
|
||||
r"/(?P<database>[^\/\.]+)/(?P<table>[^\/\.]+)/-/drop$",
|
||||
|
|
|
|||
|
|
@ -6,19 +6,17 @@ class SQLiteType(Enum):
|
|||
INTEGER = "INTEGER"
|
||||
REAL = "REAL"
|
||||
BLOB = "BLOB"
|
||||
NULL = "NULL"
|
||||
NUMERIC = "NUMERIC"
|
||||
|
||||
@classmethod
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType | None":
|
||||
def from_declared_type(cls, declared_type: str | None) -> "SQLiteType":
|
||||
if declared_type is None:
|
||||
return cls.NULL
|
||||
return cls.BLOB
|
||||
|
||||
normalized = declared_type.strip().upper()
|
||||
if not normalized:
|
||||
return cls.NULL
|
||||
return cls.BLOB
|
||||
|
||||
if normalized == cls.NULL.value:
|
||||
return cls.NULL
|
||||
if "INT" in normalized:
|
||||
return cls.INTEGER
|
||||
if any(token in normalized for token in ("CHAR", "CLOB", "TEXT")):
|
||||
|
|
@ -31,7 +29,7 @@ class SQLiteType(Enum):
|
|||
):
|
||||
return cls.REAL
|
||||
|
||||
return None
|
||||
return cls.NUMERIC
|
||||
|
||||
|
||||
class ColumnType:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ class JsonColumnType(ColumnType):
|
|||
return None
|
||||
|
||||
|
||||
class TextareaColumnType(ColumnType):
|
||||
name = "textarea"
|
||||
description = "Multiline text"
|
||||
sqlite_types = (SQLiteType.TEXT,)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def register_column_types(datasette):
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType]
|
||||
return [UrlColumnType, EmailColumnType, JsonColumnType, TextareaColumnType]
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ DEBUG_MENU_ITEMS = (
|
|||
"Debug allow rules",
|
||||
"Explore how allow blocks match actors against permission rules.",
|
||||
),
|
||||
(
|
||||
"/-/debug/autocomplete",
|
||||
"Debug autocomplete",
|
||||
"Try out table autocomplete against a detected label column.",
|
||||
),
|
||||
(
|
||||
"/-/threads",
|
||||
"Debug threads",
|
||||
|
|
|
|||
|
|
@ -159,32 +159,32 @@ def jump_items_sql(datasette, actor, request):
|
|||
|
||||
@hookspec
|
||||
def row_actions(datasette, actor, request, database, table, row):
|
||||
"""Links for the row actions menu"""
|
||||
"""Items for the row actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
"""Links for the table actions menu"""
|
||||
"""Items for the table actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def view_actions(datasette, actor, database, view, request):
|
||||
"""Links for the view actions menu"""
|
||||
"""Items for the view actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||
"""Links for the query and stored query actions menu"""
|
||||
"""Items for the query and stored query actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def database_actions(datasette, actor, database, request):
|
||||
"""Links for the database actions menu"""
|
||||
"""Items for the database actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
def homepage_actions(datasette, actor, request):
|
||||
"""Links for the homepage actions menu"""
|
||||
"""Items for the homepage actions menu"""
|
||||
|
||||
|
||||
@hookspec
|
||||
|
|
|
|||
|
|
@ -792,9 +792,9 @@ p.zero-results {
|
|||
|
||||
dialog.mobile-column-actions-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
|
|
@ -1020,9 +1020,9 @@ dialog.mobile-column-actions-dialog::backdrop {
|
|||
|
||||
dialog.set-column-type-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
|
|
@ -1109,7 +1109,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
padding: 14px 16px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
background: #fcfbf9;
|
||||
background: #fbfdff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
|
@ -1192,6 +1192,607 @@ dialog.set-column-type-dialog::backdrop {
|
|||
cursor: wait;
|
||||
}
|
||||
|
||||
.row-mutation-status {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 8px 10px;
|
||||
border-left: 4px solid #54AC8E;
|
||||
background: rgba(103,201,141,0.12);
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.row-mutation-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-mutation-status-error {
|
||||
border-left-color: #D0021B;
|
||||
background: rgba(208,2,27,0.12);
|
||||
}
|
||||
|
||||
.table-row-toolbar {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
button.table-insert-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
button.table-insert-row svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(440px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-delete-message,
|
||||
.row-delete-error {
|
||||
margin: 0;
|
||||
padding: 16px 24px 0;
|
||||
}
|
||||
|
||||
.row-delete-message {
|
||||
color: var(--ink);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.row-delete-id {
|
||||
display: inline;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-delete-error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-footer {
|
||||
padding: 18px 20px 14px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.row-delete-dialog .btn:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--card: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--modal-border-radius, 0.75rem);
|
||||
padding: 0;
|
||||
margin: auto;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
max-width: 95vw;
|
||||
max-height: min(780px, calc(100vh - 32px));
|
||||
box-shadow: var(--modal-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
|
||||
animation: datasette-modal-slide-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog::backdrop {
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
-webkit-backdrop-filter: var(--modal-backdrop-blur, blur(4px));
|
||||
animation: datasette-modal-fade-in var(--modal-animation-duration, 0.2s) ease-out;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-header {
|
||||
padding: 20px 24px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title .row-dialog-action,
|
||||
.row-delete-dialog .modal-title .row-dialog-action {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title code,
|
||||
.row-delete-dialog .modal-title code {
|
||||
display: inline;
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: var(--paper);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-title .row-dialog-label,
|
||||
.row-delete-dialog .modal-title .row-dialog-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-edit-form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-error {
|
||||
margin: 0;
|
||||
padding: 12px 24px 0;
|
||||
}
|
||||
|
||||
.row-edit-summary,
|
||||
.row-edit-loading {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
border-left: 4px solid #b91c1c;
|
||||
border-radius: 4px;
|
||||
background: #fff1f1;
|
||||
color: #7f1d1d;
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 24px 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.row-edit-error:focus {
|
||||
outline: 3px solid rgba(185, 28, 28, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.row-edit-fields {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
padding-top: 8px;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-control-wrap {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea.row-edit-input {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.row-edit-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-input[aria-invalid="true"] {
|
||||
border-color: #b42318;
|
||||
background: #fff8f7;
|
||||
}
|
||||
|
||||
.row-edit-input[aria-invalid="true"]:focus {
|
||||
border-color: #b42318;
|
||||
outline-color: rgba(180, 35, 24, 0.16);
|
||||
}
|
||||
|
||||
.row-edit-input[readonly] {
|
||||
color: var(--muted);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-default {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 7px 8px 7px 10px;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-default[hidden],
|
||||
.row-edit-custom-value[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-default-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-default-code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.row-edit-custom-value {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 7.25rem;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 45px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.row-edit-default-button {
|
||||
appearance: none;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.2;
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.row-edit-default-button:hover,
|
||||
.row-edit-default-button:focus {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.row-edit-default-button:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-edit-field-meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.row-edit-field-validation-error {
|
||||
color: #b42318;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.row-edit-field-validation-error[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-field-meta-autocomplete {
|
||||
line-height: 1.2;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.row-edit-fk-pk {
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.row-edit-fk-link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.row-edit-empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
datasette-autocomplete {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
datasette-autocomplete input[type="text"],
|
||||
.debug-autocomplete-form input[type="text"] {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 38rem;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-list {
|
||||
background: #fff;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.14);
|
||||
box-sizing: border-box;
|
||||
left: 0;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
top: auto;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-list[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-option {
|
||||
cursor: pointer;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-option:hover,
|
||||
.datasette-autocomplete-option[aria-selected="true"] {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.datasette-autocomplete-option[aria-selected="true"] {
|
||||
background: var(--paper);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.datasette-autocomplete-status {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.debug-autocomplete-demo {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.debug-autocomplete-selected {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 9px 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
font-family: inherit;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row-link-with-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.row-inline-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row-inline-action {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(74, 85, 104, 0.24);
|
||||
background: transparent;
|
||||
color: #4a5568;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-height: 24px;
|
||||
min-width: 24px;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row-inline-action:hover,
|
||||
.row-inline-action:focus {
|
||||
background: rgba(74, 85, 104, 0.07);
|
||||
}
|
||||
|
||||
.row-inline-action:focus {
|
||||
outline: 3px solid #b3d4ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.row-inline-action-icon {
|
||||
display: block;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
dialog.mobile-column-actions-dialog {
|
||||
width: 95vw;
|
||||
|
|
@ -1239,6 +1840,68 @@ dialog.set-column-type-dialog::backdrop {
|
|||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
dialog.row-delete-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-header,
|
||||
.row-delete-message,
|
||||
.row-delete-error {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-delete-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
dialog.row-edit-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-fields {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-error {
|
||||
margin-left: 18px;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.row-inline-action {
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.row-inline-action-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
@ -1293,6 +1956,10 @@ dialog.set-column-type-dialog::backdrop {
|
|||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.row-inline-actions {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.select-wrapper {
|
||||
width: 100px;
|
||||
}
|
||||
|
|
@ -1303,6 +1970,7 @@ dialog.set-column-type-dialog::backdrop {
|
|||
width: 140px;
|
||||
}
|
||||
button.choose-columns-mobile,
|
||||
button.table-insert-row,
|
||||
button.column-actions-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1339,6 +2007,15 @@ dialog.set-column-type-dialog::backdrop {
|
|||
button.choose-columns-mobile {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.table-row-toolbar {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
button.table-insert-row {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
svg.dropdown-menu-icon {
|
||||
|
|
@ -1384,18 +2061,32 @@ svg.dropdown-menu-icon {
|
|||
.dropdown-menu a:link,
|
||||
.dropdown-menu a:visited,
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu a:focus
|
||||
.dropdown-menu a:active {
|
||||
.dropdown-menu a:focus,
|
||||
.dropdown-menu a:active,
|
||||
.dropdown-menu button.action-menu-button {
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 8px 2px 8px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dropdown-menu a:hover {
|
||||
.dropdown-menu button.action-menu-button {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.dropdown-menu a:hover,
|
||||
.dropdown-menu button.action-menu-button:hover,
|
||||
.dropdown-menu button.action-menu-button:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
.dropdown-menu .dropdown-description {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.8em;
|
||||
|
|
|
|||
344
datasette/static/autocomplete.js
Normal file
344
datasette/static/autocomplete.js
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
(function () {
|
||||
function autocompleteValueFromRow(row) {
|
||||
var pks = (row && row.pks) || {};
|
||||
var keys = Object.keys(pks);
|
||||
if (!keys.length) {
|
||||
return "";
|
||||
}
|
||||
if (keys.length === 1) {
|
||||
return String(pks[keys[0]]);
|
||||
}
|
||||
return keys
|
||||
.map(function (key) {
|
||||
return key + "=" + pks[key];
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function autocompleteLabelFromRow(row) {
|
||||
var value = autocompleteValueFromRow(row);
|
||||
if (row.label && String(row.label) !== value) {
|
||||
return row.label + " (" + value + ")";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!window.customElements || customElements.get("datasette-autocomplete")) {
|
||||
return;
|
||||
}
|
||||
|
||||
class DatasetteAutocomplete extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.input = null;
|
||||
this.listbox = null;
|
||||
this.status = null;
|
||||
this.results = [];
|
||||
this.activeIndex = -1;
|
||||
this.fetchId = 0;
|
||||
this.searchTimer = null;
|
||||
this.boundInput = this.handleInput.bind(this);
|
||||
this.boundKeydown = this.handleKeydown.bind(this);
|
||||
this.boundBlur = this.handleBlur.bind(this);
|
||||
this.boundFocus = this.handleFocus.bind(this);
|
||||
this.boundPositionListbox = this.positionListbox.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.input) {
|
||||
return;
|
||||
}
|
||||
this.input = this.querySelector("input");
|
||||
if (!this.input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var inputId =
|
||||
this.input.id ||
|
||||
"datasette-autocomplete-" + Math.random().toString(36).slice(2);
|
||||
this.input.id = inputId;
|
||||
var listboxId = inputId + "-listbox";
|
||||
var statusId = inputId + "-status";
|
||||
|
||||
this.classList.add("datasette-autocomplete");
|
||||
this.input.setAttribute("role", "combobox");
|
||||
this.input.setAttribute("aria-autocomplete", "list");
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.setAttribute("aria-controls", listboxId);
|
||||
this.input.setAttribute("autocomplete", "off");
|
||||
|
||||
this.listbox = document.createElement("div");
|
||||
this.listbox.className = "datasette-autocomplete-list";
|
||||
this.listbox.id = listboxId;
|
||||
this.listbox.setAttribute("role", "listbox");
|
||||
this.listbox.hidden = true;
|
||||
|
||||
this.status = document.createElement("span");
|
||||
this.status.className = "datasette-autocomplete-status";
|
||||
this.status.id = statusId;
|
||||
this.status.setAttribute("role", "status");
|
||||
this.status.setAttribute("aria-live", "polite");
|
||||
|
||||
this.input.setAttribute(
|
||||
"aria-describedby",
|
||||
[this.input.getAttribute("aria-describedby"), statusId]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
|
||||
this.appendChild(this.listbox);
|
||||
this.appendChild(this.status);
|
||||
|
||||
this.input.addEventListener("input", this.boundInput);
|
||||
this.input.addEventListener("keydown", this.boundKeydown);
|
||||
this.input.addEventListener("blur", this.boundBlur);
|
||||
this.input.addEventListener("focus", this.boundFocus);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (!this.input) {
|
||||
return;
|
||||
}
|
||||
this.input.removeEventListener("input", this.boundInput);
|
||||
this.input.removeEventListener("keydown", this.boundKeydown);
|
||||
this.input.removeEventListener("blur", this.boundBlur);
|
||||
this.input.removeEventListener("focus", this.boundFocus);
|
||||
}
|
||||
|
||||
handleInput() {
|
||||
this.scheduleSearch();
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
if (this.input.value.trim() || this.hasAttribute("suggest-on-focus")) {
|
||||
this.scheduleSearch();
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur() {
|
||||
window.setTimeout(() => this.close(), 150);
|
||||
}
|
||||
|
||||
handleKeydown(ev) {
|
||||
if (ev.key === "Escape") {
|
||||
if (!this.listbox.hidden) {
|
||||
ev.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
if (this.listbox.hidden) {
|
||||
this.scheduleSearch();
|
||||
} else {
|
||||
this.setActiveIndex(this.activeIndex + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowUp") {
|
||||
ev.preventDefault();
|
||||
if (!this.listbox.hidden) {
|
||||
this.setActiveIndex(this.activeIndex - 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter" && !this.listbox.hidden && this.activeIndex >= 0) {
|
||||
ev.preventDefault();
|
||||
this.chooseIndex(this.activeIndex);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleSearch() {
|
||||
window.clearTimeout(this.searchTimer);
|
||||
this.searchTimer = window.setTimeout(() => this.search(), 150);
|
||||
}
|
||||
|
||||
async search() {
|
||||
var query = this.input.value.trim();
|
||||
var initial = !query && this.hasAttribute("suggest-on-focus");
|
||||
if (!query && !initial) {
|
||||
this.close();
|
||||
this.status.textContent = "";
|
||||
return;
|
||||
}
|
||||
var src = this.getAttribute("src");
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
|
||||
var url = new URL(src, location.href);
|
||||
url.searchParams.set("q", query);
|
||||
if (initial) {
|
||||
url.searchParams.set("_initial", "1");
|
||||
} else {
|
||||
url.searchParams.delete("_initial");
|
||||
}
|
||||
var fetchId = this.fetchId + 1;
|
||||
this.fetchId = fetchId;
|
||||
this.status.textContent = "Searching...";
|
||||
|
||||
try {
|
||||
var response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("HTTP " + response.status);
|
||||
}
|
||||
var data = await response.json();
|
||||
if (fetchId !== this.fetchId) {
|
||||
return;
|
||||
}
|
||||
this.results = (data && data.rows) || [];
|
||||
this.render();
|
||||
} catch (_error) {
|
||||
if (fetchId !== this.fetchId) {
|
||||
return;
|
||||
}
|
||||
this.results = [];
|
||||
this.close();
|
||||
this.status.textContent = "Could not load suggestions";
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.listbox.textContent = "";
|
||||
this.activeIndex = -1;
|
||||
if (!this.results.length) {
|
||||
this.close();
|
||||
this.status.textContent = "No matches";
|
||||
return;
|
||||
}
|
||||
|
||||
this.results.forEach((row, index) => {
|
||||
var option = document.createElement("div");
|
||||
option.className = "datasette-autocomplete-option";
|
||||
option.id = this.input.id + "-option-" + index;
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-selected", "false");
|
||||
option.dataset.index = String(index);
|
||||
option.dataset.value = autocompleteValueFromRow(row);
|
||||
option.textContent = autocompleteLabelFromRow(row);
|
||||
option.addEventListener("mousedown", (ev) => {
|
||||
ev.preventDefault();
|
||||
this.chooseIndex(index);
|
||||
});
|
||||
this.listbox.appendChild(option);
|
||||
});
|
||||
|
||||
this.listbox.hidden = false;
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this.status.textContent =
|
||||
this.results.length + (this.results.length === 1 ? " match" : " matches");
|
||||
this.positionListbox();
|
||||
this.setActiveIndex(0);
|
||||
}
|
||||
|
||||
positionListbox() {
|
||||
if (!this.input || !this.listbox || this.listbox.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
var gap = 3;
|
||||
var margin = 8;
|
||||
var inputRect = this.input.getBoundingClientRect();
|
||||
this.listbox.style.maxHeight = "";
|
||||
var defaultMaxHeight = parseFloat(
|
||||
window.getComputedStyle(this.listbox).maxHeight,
|
||||
);
|
||||
if (!Number.isFinite(defaultMaxHeight)) {
|
||||
defaultMaxHeight = 256;
|
||||
}
|
||||
var scrollHeight = Math.ceil(this.listbox.scrollHeight);
|
||||
var desiredHeight = Math.min(scrollHeight, defaultMaxHeight);
|
||||
var availableBelow = Math.max(
|
||||
0,
|
||||
(window.innerHeight || document.documentElement.clientHeight) -
|
||||
inputRect.bottom -
|
||||
gap -
|
||||
margin,
|
||||
);
|
||||
|
||||
this.listbox.style.left = inputRect.left + "px";
|
||||
this.listbox.style.top = inputRect.bottom + gap + "px";
|
||||
this.listbox.style.width = inputRect.width + "px";
|
||||
if (scrollHeight <= defaultMaxHeight && scrollHeight <= availableBelow) {
|
||||
this.listbox.style.maxHeight = "none";
|
||||
} else {
|
||||
this.listbox.style.maxHeight =
|
||||
Math.min(defaultMaxHeight, desiredHeight, availableBelow || defaultMaxHeight) +
|
||||
"px";
|
||||
}
|
||||
window.addEventListener("resize", this.boundPositionListbox);
|
||||
document.addEventListener("scroll", this.boundPositionListbox, true);
|
||||
}
|
||||
|
||||
setActiveIndex(index) {
|
||||
var options = this.listbox.querySelectorAll("[role='option']");
|
||||
if (!options.length) {
|
||||
this.activeIndex = -1;
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
return;
|
||||
}
|
||||
if (index < 0) {
|
||||
index = options.length - 1;
|
||||
}
|
||||
if (index >= options.length) {
|
||||
index = 0;
|
||||
}
|
||||
options.forEach((option, optionIndex) => {
|
||||
option.setAttribute(
|
||||
"aria-selected",
|
||||
optionIndex === index ? "true" : "false",
|
||||
);
|
||||
});
|
||||
this.activeIndex = index;
|
||||
this.input.setAttribute("aria-activedescendant", options[index].id);
|
||||
}
|
||||
|
||||
chooseIndex(index) {
|
||||
var row = this.results[index];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
var value = autocompleteValueFromRow(row);
|
||||
var label = autocompleteLabelFromRow(row);
|
||||
this.input.value = value;
|
||||
this.input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
this.close();
|
||||
this.status.textContent = "Selected " + label;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("datasette-autocomplete-select", {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
row: row,
|
||||
value: value,
|
||||
label: label,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.listbox) {
|
||||
this.listbox.hidden = true;
|
||||
this.listbox.textContent = "";
|
||||
this.listbox.style.left = "";
|
||||
this.listbox.style.maxHeight = "";
|
||||
this.listbox.style.top = "";
|
||||
this.listbox.style.width = "";
|
||||
}
|
||||
if (this.input) {
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.input.removeAttribute("aria-activedescendant");
|
||||
}
|
||||
window.removeEventListener("resize", this.boundPositionListbox);
|
||||
document.removeEventListener("scroll", this.boundPositionListbox, true);
|
||||
this.activeIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("datasette-autocomplete", DatasetteAutocomplete);
|
||||
})();
|
||||
|
|
@ -31,9 +31,9 @@ class ColumnChooser extends HTMLElement {
|
|||
<style>
|
||||
:host {
|
||||
--ink: #0f0f0f;
|
||||
--paper: #f5f3ef;
|
||||
--paper: #eef6ff;
|
||||
--muted: #6b6b6b;
|
||||
--rule: #e2dfd8;
|
||||
--rule: #d8e6f5;
|
||||
--accent: #1a56db;
|
||||
--accent-light: #e8effd;
|
||||
--card: #ffffff;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,35 @@ const datasetteManager = {
|
|||
return columnActions;
|
||||
},
|
||||
|
||||
/**
|
||||
* Allows JavaScript plugins to replace or enhance insert/edit modal fields
|
||||
* for specific Datasette column types.
|
||||
*
|
||||
* The first plugin to return a control object wins. Returning null or
|
||||
* undefined means "I do not handle this field".
|
||||
*/
|
||||
makeColumnField: (context) => {
|
||||
for (const [pluginName, plugin] of datasetteManager.plugins) {
|
||||
if (!plugin.makeColumnField) {
|
||||
continue;
|
||||
}
|
||||
let control = null;
|
||||
try {
|
||||
control = plugin.makeColumnField(context);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error in makeColumnField() for plugin ${pluginName}`,
|
||||
error,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (control) {
|
||||
return Object.assign({ pluginName }, control);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
makeJumpSections: (context) => {
|
||||
let jumpSections = [];
|
||||
|
||||
|
|
|
|||
1995
datasette/static/edit-tools.js
Normal file
1995
datasette/static/edit-tools.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,6 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
|||
|
||||
var SET_COLUMN_TYPE_DIALOG_ID = "set-column-type-dialog";
|
||||
var setColumnTypeDialogState = null;
|
||||
|
||||
function getParams() {
|
||||
return new URLSearchParams(location.search);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,22 @@
|
|||
<div class="hook"></div>
|
||||
<ul role="menu">
|
||||
{% for link in action_links %}
|
||||
<li role="none"><a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<p class="dropdown-description">{{ link.description }}</p>
|
||||
{% endif %}</a>
|
||||
<li role="none">
|
||||
{% if link.get("type") == "button" %}
|
||||
<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1"{% for name, value in (link.get("attrs") or {}).items() %} {{ name }}="{{ value }}"{% endfor %}>{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</button>
|
||||
{% else %}
|
||||
<a href="{{ link.href }}" role="menuitem" tabindex="-1">{{ link.label }}
|
||||
{% if link.description %}
|
||||
<span class="dropdown-description">{{ link.description }}</span>
|
||||
{% endif %}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in display_rows %}
|
||||
<tr>
|
||||
<tr{% if row.pk_path is not none %} data-row="{{ row.row_path }}"{% if row.row_label %} data-row-label="{{ row.row_label }}"{% endif %}{% endif %}>
|
||||
{% for cell in row %}
|
||||
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
|
||||
{% endfor %}
|
||||
|
|
|
|||
78
datasette/templates/debug_autocomplete.html
Normal file
78
datasette/templates/debug_autocomplete.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Debug autocomplete{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ super() }}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Debug autocomplete</h1>
|
||||
|
||||
<form class="core debug-autocomplete-form" action="{{ urls.path('-/debug/autocomplete') }}" method="get">
|
||||
<p>
|
||||
<label for="debug-autocomplete-database">Database</label>
|
||||
<input id="debug-autocomplete-database" type="text" name="database" value="{{ database_name or "" }}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="debug-autocomplete-table">Table</label>
|
||||
<input id="debug-autocomplete-table" type="text" name="table" value="{{ table_name or "" }}">
|
||||
</p>
|
||||
<p><input type="submit" value="Open autocomplete"></p>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<p class="message-error">{{ error }}</p>
|
||||
{% elif autocomplete_url %}
|
||||
<h2>{{ database_name }} / {{ table_name }}</h2>
|
||||
{% if label_column %}
|
||||
<p>Label column: <code>{{ label_column }}</code></p>
|
||||
{% else %}
|
||||
<p>No label column detected. Results will use primary key values.</p>
|
||||
{% endif %}
|
||||
<div class="debug-autocomplete-demo">
|
||||
<label for="debug-autocomplete-input">Search rows</label>
|
||||
<datasette-autocomplete src="{{ autocomplete_url }}">
|
||||
<input id="debug-autocomplete-input" type="text">
|
||||
</datasette-autocomplete>
|
||||
</div>
|
||||
<h3>Selected row</h3>
|
||||
<pre class="debug-autocomplete-selected" aria-live="polite">No row selected.</pre>
|
||||
<script>
|
||||
document.addEventListener("datasette-autocomplete-select", function (event) {
|
||||
var output = document.querySelector(".debug-autocomplete-selected");
|
||||
if (output) {
|
||||
output.textContent = JSON.stringify(event.detail.row, null, 2);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% else %}
|
||||
<h2>Suggested tables</h2>
|
||||
{% if suggestions %}
|
||||
<p>Showing up to five tables with a detected label column.</p>
|
||||
<table class="rows-and-columns">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Database</th>
|
||||
<th>Table</th>
|
||||
<th>Label column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for suggestion in suggestions %}
|
||||
<tr>
|
||||
<td>{{ suggestion.database }}</td>
|
||||
<td><a href="{{ suggestion.url }}">{{ suggestion.table }}</a></td>
|
||||
<td><code>{{ suggestion.label_column }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No tables with detected label columns found.</p>
|
||||
{% endif %}
|
||||
<p>Scanned {{ scanned }} table{% if scanned != 1 %}s{% endif %}{% if reached_scan_limit %}; stopped at the 100 table scan limit{% endif %}.</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -4,6 +4,13 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
{% if row_mutation_ui %}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
{% endif %}
|
||||
<style>
|
||||
@media only screen and (max-width: 576px) {
|
||||
{% for column in columns %}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,13 @@
|
|||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
<script>window._datasetteTableData = {{ table_page_data|tojson }};</script>
|
||||
<script src="{{ urls.static('column-chooser.js') }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}" defer></script>
|
||||
{% if table_page_data.foreignKeys %}
|
||||
<script src="{{ urls.static('autocomplete.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<script src="{{ urls.static('edit-tools.js') }}?hash={{ edit_tools_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('table.js') }}?hash={{ table_js_hash }}" defer></script>
|
||||
<script src="{{ urls.static('mobile-column-actions.js') }}" defer></script>
|
||||
<script>DATASETTE_ALLOW_FACET = {{ datasette_allow_facet }};</script>
|
||||
<style>
|
||||
|
|
@ -158,6 +163,19 @@ window._setColumnTypeData = {{ set_column_type_ui|tojson }};
|
|||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if table_insert_ui %}
|
||||
<div class="table-row-toolbar">
|
||||
<button type="button" class="core table-insert-row" data-table-action="insert-row">
|
||||
<svg class="row-inline-action-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2"></rect>
|
||||
<path d="M8 12h8"></path>
|
||||
<path d="M12 8v8"></path>
|
||||
</svg>
|
||||
<span>Insert row</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include custom_table_templates %}
|
||||
|
||||
{% if next_url %}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from datasette.utils import (
|
|||
await_me_maybe,
|
||||
CustomRow,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
to_css_class,
|
||||
escape_sqlite,
|
||||
)
|
||||
|
|
@ -15,7 +16,11 @@ import json
|
|||
import markupsafe
|
||||
import sqlite_utils
|
||||
from datasette.extras import extra_names_from_request
|
||||
from .table import display_columns_and_rows
|
||||
from .table import (
|
||||
display_columns_and_rows,
|
||||
_table_page_data,
|
||||
row_label_from_label_column,
|
||||
)
|
||||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||
|
||||
|
||||
|
|
@ -49,6 +54,7 @@ class RowView(DataView):
|
|||
pks = resolved.pks
|
||||
|
||||
async def template_data():
|
||||
is_table = await db.table_exists(table)
|
||||
# Reorder columns so primary keys come first
|
||||
pk_set = set(pks)
|
||||
pk_cols = [d for d in results.description if d[0] in pk_set]
|
||||
|
|
@ -117,7 +123,60 @@ class RowView(DataView):
|
|||
"<strong>{}</strong>".format(cell["value"])
|
||||
)
|
||||
|
||||
label_column = await db.label_column_for_table(table) if is_table else None
|
||||
row_path = path_from_row_pks(rows[0], pks, False)
|
||||
pk_path = path_from_row_pks(rows[0], pks, False, False)
|
||||
row_label = row_label_from_label_column(expanded_rows[0], label_column)
|
||||
for display_row in display_rows:
|
||||
display_row.pk_path = pk_path
|
||||
display_row.row_path = row_path
|
||||
display_row.row_label = row_label
|
||||
|
||||
row_action_label = pk_path
|
||||
if row_label and row_label != pk_path:
|
||||
row_action_label = "{} {}".format(pk_path, row_label)
|
||||
|
||||
row_action_permissions = {}
|
||||
if is_table and db.is_mutable:
|
||||
row_action_permissions = await self.ds.allowed_many(
|
||||
actions=["update-row", "delete-row"],
|
||||
resource=TableResource(database=database, table=table),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
row_actions = []
|
||||
if row_action_permissions.get("update-row"):
|
||||
attrs = {
|
||||
"aria-label": "Edit row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "edit",
|
||||
}
|
||||
if row_label:
|
||||
attrs["data-row-label"] = row_label
|
||||
row_actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Edit row",
|
||||
"description": "Open a dialog to edit this row.",
|
||||
"attrs": attrs,
|
||||
}
|
||||
)
|
||||
if row_action_permissions.get("delete-row"):
|
||||
attrs = {
|
||||
"aria-label": "Delete row {}".format(row_action_label),
|
||||
"data-row": row_path,
|
||||
"data-row-action": "delete",
|
||||
}
|
||||
if row_label:
|
||||
attrs["data-row-label"] = row_label
|
||||
row_actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Delete row",
|
||||
"description": "Open a confirmation dialog to delete this row.",
|
||||
"attrs": attrs,
|
||||
}
|
||||
)
|
||||
for hook in pm.hook.row_actions(
|
||||
datasette=self.ds,
|
||||
actor=request.actor,
|
||||
|
|
@ -144,6 +203,16 @@ class RowView(DataView):
|
|||
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
||||
"_table.html",
|
||||
],
|
||||
"row_mutation_ui": any(row_action_permissions.values()),
|
||||
"table_page_data": await _table_page_data(
|
||||
self.ds,
|
||||
request,
|
||||
db,
|
||||
database,
|
||||
table,
|
||||
not is_table,
|
||||
None,
|
||||
),
|
||||
"row_actions": row_actions,
|
||||
"top_row": make_slot_function(
|
||||
"top_row",
|
||||
|
|
@ -249,6 +318,27 @@ class RowError(Exception):
|
|||
self.error = error
|
||||
|
||||
|
||||
ROW_FLASH_LABEL_MAX_LENGTH = 80
|
||||
|
||||
|
||||
def _truncated_row_flash_label(label):
|
||||
label = " ".join(str(label).split())
|
||||
if len(label) <= ROW_FLASH_LABEL_MAX_LENGTH:
|
||||
return label
|
||||
return label[: ROW_FLASH_LABEL_MAX_LENGTH - 1] + "\u2026"
|
||||
|
||||
|
||||
async def _row_flash_message(db, action, resolved, row=None):
|
||||
pk_label = ", ".join(resolved.pk_values)
|
||||
label_column = await db.label_column_for_table(resolved.table)
|
||||
label = row_label_from_label_column(row or resolved.row, label_column)
|
||||
if label:
|
||||
label = _truncated_row_flash_label(label)
|
||||
if label and label != pk_label:
|
||||
return "{} row {} ({})".format(action, pk_label, label)
|
||||
return "{} row {}".format(action, pk_label)
|
||||
|
||||
|
||||
async def _resolve_row_and_check_permission(datasette, request, permission):
|
||||
from datasette.app import DatabaseNotFound, TableNotFound, RowNotFound
|
||||
|
||||
|
|
@ -303,6 +393,15 @@ class RowDeleteView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
if request.args.get("_redirect_to_table"):
|
||||
table_url = self.ds.urls.table(resolved.db.name, resolved.table)
|
||||
self.ds.add_message(
|
||||
request,
|
||||
await _row_flash_message(resolved.db, "Deleted", resolved),
|
||||
self.ds.INFO,
|
||||
)
|
||||
return Response.json({"ok": True, "redirect": str(table_url)}, status=200)
|
||||
|
||||
return Response.json({"ok": True}, status=200)
|
||||
|
||||
|
||||
|
|
@ -364,11 +463,13 @@ class RowUpdateView(BaseView):
|
|||
return _error([str(e)], 400)
|
||||
|
||||
result = {"ok": True}
|
||||
returned_row = None
|
||||
if data.get("return"):
|
||||
results = await resolved.db.execute(
|
||||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
result["row"] = results.dicts()[0]
|
||||
returned_row = results.dicts()[0]
|
||||
result["row"] = returned_row
|
||||
|
||||
await self.ds.track_event(
|
||||
UpdateRowEvent(
|
||||
|
|
@ -379,4 +480,19 @@ class RowUpdateView(BaseView):
|
|||
)
|
||||
)
|
||||
|
||||
if request.args.get("_message"):
|
||||
message_row = returned_row
|
||||
if message_row is None:
|
||||
results = await resolved.db.execute(
|
||||
resolved.sql, resolved.params, truncate=True
|
||||
)
|
||||
message_row = results.first()
|
||||
self.ds.add_message(
|
||||
request,
|
||||
await _row_flash_message(
|
||||
resolved.db, "Updated", resolved, row=message_row
|
||||
),
|
||||
self.ds.INFO,
|
||||
)
|
||||
|
||||
return Response.json(result, status=200)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,110 @@ class PatternPortfolioView(View):
|
|||
)
|
||||
|
||||
|
||||
class AutocompleteDebugView(BaseView):
|
||||
name = "autocomplete_debug"
|
||||
has_json_alternate = False
|
||||
|
||||
async def _suggested_tables(self, request):
|
||||
scanned = 0
|
||||
reached_scan_limit = False
|
||||
suggestions = []
|
||||
for database_name, db in self.ds.databases.items():
|
||||
if scanned >= 100 or len(suggestions) >= 5:
|
||||
break
|
||||
remaining = 100 - scanned
|
||||
results = await db.execute(
|
||||
"select name from sqlite_master where type = 'table' order by name limit ?",
|
||||
[remaining],
|
||||
)
|
||||
for row in results.rows:
|
||||
table_name = row["name"]
|
||||
scanned += 1
|
||||
if scanned >= 100:
|
||||
reached_scan_limit = True
|
||||
visible, _ = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
action="view-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
)
|
||||
if not visible:
|
||||
if scanned >= 100:
|
||||
break
|
||||
continue
|
||||
label_column = await db.label_column_for_table(table_name)
|
||||
if label_column:
|
||||
suggestions.append(
|
||||
{
|
||||
"database": database_name,
|
||||
"table": table_name,
|
||||
"label_column": label_column,
|
||||
"url": self.ds.urls.path(
|
||||
"-/debug/autocomplete?"
|
||||
+ urllib.parse.urlencode(
|
||||
{
|
||||
"database": database_name,
|
||||
"table": table_name,
|
||||
}
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
if len(suggestions) >= 5:
|
||||
break
|
||||
if scanned >= 100:
|
||||
break
|
||||
return suggestions, scanned, reached_scan_limit
|
||||
|
||||
async def get(self, request):
|
||||
await self.ds.ensure_permission(action="view-instance", actor=request.actor)
|
||||
database_name = request.args.get("database")
|
||||
table_name = request.args.get("table")
|
||||
context = {
|
||||
"database_name": database_name,
|
||||
"table_name": table_name,
|
||||
}
|
||||
|
||||
if database_name or table_name:
|
||||
if not database_name or not table_name:
|
||||
context["error"] = "Both database and table are required."
|
||||
elif database_name not in self.ds.databases:
|
||||
context["error"] = "Database not found."
|
||||
else:
|
||||
db = self.ds.databases[database_name]
|
||||
if not await db.table_exists(table_name):
|
||||
context["error"] = "Table not found."
|
||||
else:
|
||||
await self.ds.ensure_permission(
|
||||
action="view-table",
|
||||
resource=TableResource(
|
||||
database=database_name,
|
||||
table=table_name,
|
||||
),
|
||||
actor=request.actor,
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"autocomplete_url": "{}/-/autocomplete".format(
|
||||
self.ds.urls.table(database_name, table_name)
|
||||
),
|
||||
"label_column": await db.label_column_for_table(table_name),
|
||||
}
|
||||
)
|
||||
else:
|
||||
suggestions, scanned, reached_scan_limit = await self._suggested_tables(
|
||||
request
|
||||
)
|
||||
context.update(
|
||||
{
|
||||
"suggestions": suggestions,
|
||||
"scanned": scanned,
|
||||
"reached_scan_limit": reached_scan_limit,
|
||||
}
|
||||
)
|
||||
|
||||
return await self.render(["debug_autocomplete.html"], request, context)
|
||||
|
||||
|
||||
class AuthTokenView(BaseView):
|
||||
name = "auth_token"
|
||||
has_json_alternate = False
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import asyncio
|
|||
import itertools
|
||||
import json
|
||||
import urllib
|
||||
import urllib.parse
|
||||
|
||||
import markupsafe
|
||||
|
||||
from datasette.column_types import SQLiteType
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.plugins import pm
|
||||
from datasette.events import (
|
||||
|
|
@ -13,6 +15,7 @@ from datasette.events import (
|
|||
InsertRowsEvent,
|
||||
UpsertRowsEvent,
|
||||
)
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette import tracer
|
||||
from datasette.resources import DatabaseResource, TableResource
|
||||
from datasette.utils import (
|
||||
|
|
@ -40,7 +43,7 @@ from datasette.utils import (
|
|||
InvalidSql,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Response
|
||||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
|
||||
from datasette.filters import Filters
|
||||
import sqlite_utils
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
|
|
@ -59,8 +62,17 @@ LINK_WITH_VALUE = '<a href="{base_url}{database}/{table}/{link_id}">{id}</a>'
|
|||
|
||||
|
||||
class Row:
|
||||
def __init__(self, cells):
|
||||
def __init__(
|
||||
self,
|
||||
cells,
|
||||
pk_path=None,
|
||||
row_path=None,
|
||||
row_label=None,
|
||||
):
|
||||
self.cells = cells
|
||||
self.pk_path = pk_path
|
||||
self.row_path = row_path
|
||||
self.row_label = row_label
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.cells)
|
||||
|
|
@ -87,6 +99,20 @@ class Row:
|
|||
return json.dumps(d, default=repr, indent=2)
|
||||
|
||||
|
||||
def row_label_from_label_column(row, label_column):
|
||||
if not label_column:
|
||||
return None
|
||||
try:
|
||||
value = row[label_column]
|
||||
except (KeyError, IndexError):
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
value = value.get("label")
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
|
||||
async def run_sequential(*args):
|
||||
# This used to be swappable for asyncio.gather() to run things in
|
||||
# parallel, but this lead to hard-to-debug locking issues with
|
||||
|
|
@ -97,6 +123,66 @@ async def run_sequential(*args):
|
|||
return results
|
||||
|
||||
|
||||
def _exact_filter_key(column):
|
||||
if column.startswith("_"):
|
||||
return f"{column}__exact"
|
||||
return column
|
||||
|
||||
|
||||
def _request_with_query_string(request, query_string):
|
||||
scope = dict(request.scope)
|
||||
scope["query_string"] = query_string.encode("latin-1")
|
||||
return Request(scope, request.receive)
|
||||
|
||||
|
||||
async def _fragment_request_for_row(request, resolved):
|
||||
row_path = request.args.get("_row")
|
||||
if not row_path:
|
||||
return request
|
||||
if resolved.is_view:
|
||||
raise BadRequest("_row is not supported for views")
|
||||
|
||||
pks = await resolved.db.primary_keys(resolved.table)
|
||||
row_pks = pks or ["rowid"]
|
||||
pk_values = urlsafe_components(row_path)
|
||||
if len(pk_values) != len(row_pks):
|
||||
raise BadRequest("_row does not match the primary key for this table")
|
||||
|
||||
row_pk_filter_keys = {
|
||||
key
|
||||
for pk in row_pks
|
||||
for key in {
|
||||
_exact_filter_key(pk),
|
||||
f"{pk}__exact",
|
||||
}
|
||||
}
|
||||
args = [
|
||||
(key, value)
|
||||
for key, value in urllib.parse.parse_qsl(
|
||||
request.query_string, keep_blank_values=True
|
||||
)
|
||||
if key
|
||||
not in {
|
||||
"_row",
|
||||
"_next",
|
||||
"_nocount",
|
||||
"_nofacet",
|
||||
"_nosuggest",
|
||||
}.union(row_pk_filter_keys)
|
||||
]
|
||||
args.extend(
|
||||
[(_exact_filter_key(pk), value) for pk, value in zip(row_pks, pk_values)]
|
||||
)
|
||||
args.extend(
|
||||
[
|
||||
("_nocount", "1"),
|
||||
("_nofacet", "1"),
|
||||
("_nosuggest", "1"),
|
||||
]
|
||||
)
|
||||
return _request_with_query_string(request, urllib.parse.urlencode(args))
|
||||
|
||||
|
||||
def _redirect(datasette, request, path, forward_querystring=True, remove_args=None):
|
||||
if request.query_string and "?" not in path and forward_querystring:
|
||||
path = f"{path}?{request.query_string}"
|
||||
|
|
@ -155,6 +241,116 @@ async def _validate_column_types(datasette, database_name, table_name, rows):
|
|||
return errors
|
||||
|
||||
|
||||
def _column_value_kind_for_insert_form(column_detail):
|
||||
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
||||
if sqlite_type in (SQLiteType.INTEGER, SQLiteType.REAL):
|
||||
return "number"
|
||||
return "string"
|
||||
|
||||
|
||||
def _column_sqlite_type_for_insert_form(column_detail):
|
||||
sqlite_type = SQLiteType.from_declared_type(column_detail.type)
|
||||
return sqlite_type.value if sqlite_type is not None else None
|
||||
|
||||
|
||||
async def _foreign_key_autocomplete_urls(
|
||||
datasette, request, db, database_name, table_name
|
||||
):
|
||||
autocomplete_urls = {}
|
||||
for fk in await db.foreign_keys_for_table(table_name):
|
||||
if not await db.table_exists(fk["other_table"]):
|
||||
continue
|
||||
other_pks = await db.primary_keys(fk["other_table"])
|
||||
other_column = fk["other_column"]
|
||||
if other_column is None and len(other_pks) == 1:
|
||||
other_column = other_pks[0]
|
||||
if len(other_pks) != 1 or other_column != other_pks[0]:
|
||||
continue
|
||||
visible, _ = await datasette.check_visibility(
|
||||
request.actor,
|
||||
action="view-table",
|
||||
resource=TableResource(database=database_name, table=fk["other_table"]),
|
||||
)
|
||||
if not visible:
|
||||
continue
|
||||
autocomplete_urls[fk["column"]] = "{}/-/autocomplete".format(
|
||||
datasette.urls.table(database_name, fk["other_table"])
|
||||
)
|
||||
return autocomplete_urls
|
||||
|
||||
|
||||
async def _table_page_data(
|
||||
datasette, request, db, database_name, table_name, is_view, table_insert_ui
|
||||
):
|
||||
data = {
|
||||
"database": database_name,
|
||||
"table": table_name,
|
||||
"tableUrl": datasette.urls.table(database_name, table_name),
|
||||
}
|
||||
if table_insert_ui:
|
||||
data["insertRow"] = table_insert_ui
|
||||
if not is_view:
|
||||
foreign_keys = await _foreign_key_autocomplete_urls(
|
||||
datasette, request, db, database_name, table_name
|
||||
)
|
||||
if foreign_keys:
|
||||
data["foreignKeys"] = foreign_keys
|
||||
return data
|
||||
|
||||
|
||||
async def _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
):
|
||||
if is_view or not db.is_mutable:
|
||||
return None
|
||||
|
||||
if not await datasette.allowed(
|
||||
action="insert-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
):
|
||||
return None
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
columns = []
|
||||
column_details = await db.table_column_details(table_name)
|
||||
for column in column_details:
|
||||
if column.hidden:
|
||||
continue
|
||||
is_pk = column.name in pks
|
||||
is_auto_pk = (
|
||||
is_pk
|
||||
and len(pks) == 1
|
||||
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
|
||||
)
|
||||
if is_auto_pk:
|
||||
continue
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"sqlite_type": _column_sqlite_type_for_insert_form(column),
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": is_pk,
|
||||
"value_kind": _column_value_kind_for_insert_form(column),
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"primaryKeys": pks,
|
||||
}
|
||||
|
||||
|
||||
async def display_columns_and_rows(
|
||||
datasette,
|
||||
database_name,
|
||||
|
|
@ -194,6 +390,16 @@ async def display_columns_and_rows(
|
|||
pks_for_display = pks
|
||||
if not pks_for_display:
|
||||
pks_for_display = ["rowid"]
|
||||
label_column = None
|
||||
if link_column:
|
||||
label_column = await db.label_column_for_table(table_name)
|
||||
row_action_permissions = {}
|
||||
if link_column and request is not None and db.is_mutable:
|
||||
row_action_permissions = await datasette.allowed_many(
|
||||
actions=["update-row", "delete-row"],
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
columns = []
|
||||
for r in description:
|
||||
|
|
@ -233,19 +439,72 @@ async def display_columns_and_rows(
|
|||
if link_column:
|
||||
is_special_link_column = len(pks) != 1
|
||||
pk_path = path_from_row_pks(row, pks, not pks, False)
|
||||
row_path = path_from_row_pks(row, pks, not pks)
|
||||
row_label = row_label_from_label_column(row, label_column)
|
||||
row_action_label = pk_path
|
||||
if row_label and row_label != pk_path:
|
||||
row_action_label = "{} {}".format(pk_path, row_label)
|
||||
table_path = datasette.urls.table(database_name, table_name)
|
||||
row_link = '<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
|
||||
table_path=table_path,
|
||||
flat_pks=str(markupsafe.escape(pk_path)),
|
||||
flat_pks_quoted=row_path,
|
||||
)
|
||||
edit_icon = (
|
||||
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
||||
'xmlns="http://www.w3.org/2000/svg" width="14" height="14" '
|
||||
'viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
||||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
||||
'<path d="M12 20h9"></path>'
|
||||
'<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"></path>'
|
||||
"</svg>"
|
||||
)
|
||||
delete_icon = (
|
||||
'<svg class="row-inline-action-icon" aria-hidden="true" '
|
||||
'xmlns="http://www.w3.org/2000/svg" width="14" height="14" '
|
||||
'viewBox="0 0 24 24" fill="none" stroke="currentColor" '
|
||||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'
|
||||
'<path d="M3 6h18"></path>'
|
||||
'<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>'
|
||||
'<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>'
|
||||
'<path d="M10 11v6"></path>'
|
||||
'<path d="M14 11v6"></path>'
|
||||
"</svg>"
|
||||
)
|
||||
row_actions = []
|
||||
if row_action_permissions.get("update-row"):
|
||||
row_actions.append(
|
||||
'<button type="button" class="row-inline-action row-inline-action-edit" '
|
||||
'aria-label="Edit row {row_label}" title="Edit row" '
|
||||
'data-row-action="edit">'
|
||||
"{edit_icon}</button>".format(
|
||||
edit_icon=edit_icon,
|
||||
row_label=markupsafe.escape(row_action_label),
|
||||
)
|
||||
)
|
||||
if row_action_permissions.get("delete-row"):
|
||||
row_actions.append(
|
||||
'<button type="button" class="row-inline-action row-inline-action-delete" '
|
||||
'aria-label="Delete row {row_label}" title="Delete row" '
|
||||
'data-row-action="delete">'
|
||||
"{delete_icon}</button>".format(
|
||||
delete_icon=delete_icon,
|
||||
row_label=markupsafe.escape(row_action_label),
|
||||
)
|
||||
)
|
||||
if row_actions:
|
||||
row_link = (
|
||||
'<span class="row-link-with-actions">{row_link}'
|
||||
'<span class="row-inline-actions" aria-label="Row actions">'
|
||||
"{row_actions}</span></span>"
|
||||
).format(row_link=row_link, row_actions="".join(row_actions))
|
||||
cells.append(
|
||||
{
|
||||
"column": pks[0] if len(pks) == 1 else "Link",
|
||||
"value_type": "pk",
|
||||
"is_special_link_column": is_special_link_column,
|
||||
"raw": pk_path,
|
||||
"value": markupsafe.Markup(
|
||||
'<a href="{table_path}/{flat_pks_quoted}">{flat_pks}</a>'.format(
|
||||
table_path=datasette.urls.table(database_name, table_name),
|
||||
flat_pks=str(markupsafe.escape(pk_path)),
|
||||
flat_pks_quoted=path_from_row_pks(row, pks, not pks),
|
||||
)
|
||||
),
|
||||
"value": markupsafe.Markup(row_link),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -351,7 +610,17 @@ async def display_columns_and_rows(
|
|||
),
|
||||
}
|
||||
)
|
||||
cell_rows.append(Row(cells))
|
||||
if link_column:
|
||||
cell_rows.append(
|
||||
Row(
|
||||
cells,
|
||||
pk_path=pk_path,
|
||||
row_path=row_path,
|
||||
row_label=row_label,
|
||||
)
|
||||
)
|
||||
else:
|
||||
cell_rows.append(Row(cells))
|
||||
|
||||
if link_column:
|
||||
# Add the link column header.
|
||||
|
|
@ -854,6 +1123,220 @@ class TableDropView(BaseView):
|
|||
return Response.json({"ok": True}, status=200)
|
||||
|
||||
|
||||
class TableFragmentView(BaseView):
|
||||
name = "table-fragment"
|
||||
|
||||
def __init__(self, datasette):
|
||||
self.ds = datasette
|
||||
|
||||
async def get(self, request):
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
request = await _fragment_request_for_row(request, resolved)
|
||||
view_data = await table_view_data(
|
||||
self.ds,
|
||||
request,
|
||||
resolved,
|
||||
extra_extras={"_html"},
|
||||
context_for_html_hack=True,
|
||||
default_labels=True,
|
||||
)
|
||||
if isinstance(view_data, Response):
|
||||
return view_data
|
||||
data, _rows, _columns, _expanded_columns, _sql, _next_url = view_data
|
||||
templates = data["custom_table_templates"]
|
||||
html = await self.ds.render_template(
|
||||
templates,
|
||||
dict(
|
||||
data,
|
||||
append_querystring=append_querystring,
|
||||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=self.ds.urls.path,
|
||||
settings=self.ds.settings_dict(),
|
||||
count_limit=resolved.db.count_limit,
|
||||
),
|
||||
request=request,
|
||||
view_name="table",
|
||||
)
|
||||
return Response.html(html)
|
||||
|
||||
|
||||
def _escape_like(value):
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
# Returns the exclusive upper bound for an indexed prefix search:
|
||||
# For example, values beginning with "abc" fall below the next prefix boundary.
|
||||
# The LIKE clause is still applied separately for exact escaped-LIKE semantics.
|
||||
def _prefix_range_end(value):
|
||||
if not value:
|
||||
return None
|
||||
characters = list(value)
|
||||
for i in range(len(characters) - 1, -1, -1):
|
||||
if ord(characters[i]) < 0x10FFFF:
|
||||
return "{}{}".format("".join(characters[:i]), chr(ord(characters[i]) + 1))
|
||||
return None
|
||||
|
||||
|
||||
def _autocomplete_like(column):
|
||||
return "{} like :like escape char(92)".format(escape_sqlite(column))
|
||||
|
||||
|
||||
def _autocomplete_prefix_like(column):
|
||||
return "{} like :prefix escape char(92)".format(escape_sqlite(column))
|
||||
|
||||
|
||||
def _autocomplete_order_by(pks, label_column, exact_pk, label_matches_first=True):
|
||||
clauses = []
|
||||
if exact_pk:
|
||||
clauses.append(
|
||||
"case when cast({} as text) = :q then 0 else 1 end".format(
|
||||
escape_sqlite(pks[0])
|
||||
)
|
||||
)
|
||||
if label_column:
|
||||
label_like = _autocomplete_like(label_column)
|
||||
if label_matches_first:
|
||||
clauses.append("case when {} then 0 else 1 end".format(label_like))
|
||||
clauses.append(
|
||||
"case when {} then length(cast({} as text)) end".format(
|
||||
label_like, escape_sqlite(label_column)
|
||||
)
|
||||
)
|
||||
else:
|
||||
clauses.append("length(cast({} as text))".format(escape_sqlite(pks[0])))
|
||||
clauses.extend(escape_sqlite(pk) for pk in pks)
|
||||
return ", ".join(clauses)
|
||||
|
||||
|
||||
def _autocomplete_pk_order_by(pks):
|
||||
return ", ".join(escape_sqlite(pk) for pk in pks)
|
||||
|
||||
|
||||
def _autocomplete_initial_order_by(pks):
|
||||
order_by = [f"{escape_sqlite(pks[0])} desc"]
|
||||
order_by.extend(escape_sqlite(pk) for pk in pks[1:])
|
||||
return ", ".join(order_by)
|
||||
|
||||
|
||||
def _autocomplete_response_rows(rows, pks, label_column):
|
||||
response_rows = []
|
||||
for row in rows:
|
||||
item = {"pks": {pk: row[pk] for pk in pks}}
|
||||
if label_column:
|
||||
item["label"] = row[label_column]
|
||||
response_rows.append(item)
|
||||
return response_rows
|
||||
|
||||
|
||||
AUTOCOMPLETE_TIME_LIMIT_MS = 500
|
||||
|
||||
|
||||
class TableAutocompleteView(BaseView):
|
||||
name = "table-autocomplete"
|
||||
|
||||
async def get(self, request):
|
||||
resolved = await self.ds.resolve_table(request)
|
||||
if resolved.is_view:
|
||||
raise BadRequest("Autocomplete is only available for tables")
|
||||
|
||||
db = resolved.db
|
||||
database_name = db.name
|
||||
table_name = resolved.table
|
||||
visible, _ = await self.ds.check_visibility(
|
||||
request.actor,
|
||||
action="view-table",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
)
|
||||
if not visible:
|
||||
raise Forbidden("You do not have permission to view this table")
|
||||
|
||||
pks = await db.primary_keys(table_name)
|
||||
if not pks:
|
||||
pks = ["rowid"]
|
||||
label_column = await db.label_column_for_table(table_name)
|
||||
select_columns = list(
|
||||
dict.fromkeys(pks + ([label_column] if label_column else []))
|
||||
)
|
||||
select_sql = ", ".join(escape_sqlite(column) for column in select_columns)
|
||||
q = request.args.get("q") or ""
|
||||
initial_arg = request.args.get("_initial")
|
||||
initial = (
|
||||
not q
|
||||
and initial_arg is not None
|
||||
and initial_arg != ""
|
||||
and value_as_boolean(initial_arg)
|
||||
)
|
||||
if not q and not initial:
|
||||
return Response.json({"rows": []})
|
||||
params = {
|
||||
"q": q,
|
||||
"like": "%{}%".format(_escape_like(q)),
|
||||
"prefix": "{}%".format(_escape_like(q)),
|
||||
}
|
||||
|
||||
like_columns = pks[:]
|
||||
if label_column and label_column not in like_columns:
|
||||
like_columns.append(label_column)
|
||||
where_sql = " or ".join(_autocomplete_like(column) for column in like_columns)
|
||||
exact_pk = len(pks) == 1
|
||||
order_by = _autocomplete_order_by(pks, label_column, exact_pk)
|
||||
|
||||
if initial:
|
||||
where_sql = "1 = 1"
|
||||
order_by = _autocomplete_initial_order_by(pks)
|
||||
|
||||
sql = """
|
||||
select {select_sql}
|
||||
from {table}
|
||||
where {where}
|
||||
order by {order_by}
|
||||
limit 10
|
||||
""".format(
|
||||
select_sql=select_sql,
|
||||
table=escape_sqlite(table_name),
|
||||
where=where_sql,
|
||||
order_by=order_by,
|
||||
)
|
||||
|
||||
try:
|
||||
results = await db.execute(
|
||||
sql, params, custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS
|
||||
)
|
||||
except QueryInterrupted:
|
||||
fallback_where = _autocomplete_prefix_like(pks[0])
|
||||
prefix_end = _prefix_range_end(q)
|
||||
if prefix_end:
|
||||
params["prefix_end"] = prefix_end
|
||||
first_pk = escape_sqlite(pks[0])
|
||||
fallback_where = (
|
||||
"{first_pk} >= :q and {first_pk} < :prefix_end and {like}"
|
||||
).format(first_pk=first_pk, like=fallback_where)
|
||||
fallback_sql = """
|
||||
select {select_sql}
|
||||
from {table}
|
||||
where {where}
|
||||
order by {order_by}
|
||||
limit 10
|
||||
""".format(
|
||||
select_sql=select_sql,
|
||||
table=escape_sqlite(table_name),
|
||||
where=fallback_where,
|
||||
order_by=_autocomplete_pk_order_by(pks),
|
||||
)
|
||||
try:
|
||||
results = await db.execute(
|
||||
fallback_sql,
|
||||
params,
|
||||
custom_time_limit=AUTOCOMPLETE_TIME_LIMIT_MS,
|
||||
)
|
||||
except QueryInterrupted:
|
||||
return Response.json({"rows": []})
|
||||
|
||||
return Response.json(
|
||||
{"rows": _autocomplete_response_rows(results.rows, pks, label_column)}
|
||||
)
|
||||
|
||||
|
||||
async def _columns_to_select(table_columns, pks, request):
|
||||
columns = list(table_columns)
|
||||
if "_col" in request.args:
|
||||
|
|
@ -1583,6 +2066,19 @@ async def table_view_data(
|
|||
sort = "rowid"
|
||||
data["sort"] = sort
|
||||
data["sort_desc"] = sort_desc
|
||||
table_insert_ui = await _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
data["table_insert_ui"] = table_insert_ui
|
||||
data["table_page_data"] = await _table_page_data(
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
database_name,
|
||||
table_name,
|
||||
is_view,
|
||||
table_insert_ui,
|
||||
)
|
||||
|
||||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
||||
|
|
|
|||
|
|
@ -1102,9 +1102,9 @@ These configure :ref:`full-text search <full_text_search>` for a table or view.
|
|||
``column_types``
|
||||
^^^^^^^^^^^^^^^^
|
||||
|
||||
You can assign semantic column types to columns, which affect how values are rendered, validated, and transformed. Built-in column types include ``url``, ``email``, and ``json``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook.
|
||||
You can assign semantic column types to columns, which affect how values are rendered, validated, transformed, and edited. Built-in column types include ``url``, ``email``, ``json``, and ``textarea``. Plugins can register additional column types using the :ref:`register_column_types <plugin_register_column_types>` plugin hook.
|
||||
|
||||
Column types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, and ``json`` column types are all restricted to ``TEXT`` columns.
|
||||
Column types can optionally declare which SQLite column types they apply to using ``sqlite_types``. Datasette will reject incompatible assignments. The built-in ``url``, ``email``, ``json``, and ``textarea`` column types are all restricted to ``TEXT`` columns.
|
||||
|
||||
The simplest form maps column names to type name strings:
|
||||
|
||||
|
|
@ -1119,6 +1119,7 @@ The simplest form maps column names to type name strings:
|
|||
website: url
|
||||
contact: email
|
||||
extra_data: json
|
||||
notes: textarea
|
||||
""").strip()
|
||||
)
|
||||
.. ]]]
|
||||
|
|
@ -1135,6 +1136,7 @@ The simplest form maps column names to type name strings:
|
|||
website: url
|
||||
contact: email
|
||||
extra_data: json
|
||||
notes: textarea
|
||||
|
||||
.. tab:: datasette.json
|
||||
|
||||
|
|
@ -1148,7 +1150,8 @@ The simplest form maps column names to type name strings:
|
|||
"column_types": {
|
||||
"website": "url",
|
||||
"contact": "email",
|
||||
"extra_data": "json"
|
||||
"extra_data": "json",
|
||||
"notes": "textarea"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,13 +274,28 @@ Here is an example of a custom ``_table.html`` template:
|
|||
.. code-block:: jinja
|
||||
|
||||
{% for row in display_rows %}
|
||||
<div>
|
||||
<div data-row="{{ row.row_path }}">
|
||||
<h2>{{ row["title"] }}</h2>
|
||||
<p>{{ row["description"] }}<lp>
|
||||
<p>Category: {{ row.display("category_id") }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
If your custom table template should support Datasette's row editing UI, include
|
||||
``data-row="{{ row.row_path }}"`` on the outer element that represents each row.
|
||||
This does not need to be a ``<tr>``: it can be a ``<div>``, ``<li>`` or any other
|
||||
element that wraps the HTML for that row. Datasette uses this attribute to find
|
||||
the element to remove after a delete, or replace after an edit. Any edit or
|
||||
delete controls should be rendered inside that same element.
|
||||
|
||||
The ``_action_menu.html`` template renders the action menus used by database,
|
||||
table, query and row pages. Plugin-provided actions can be link dictionaries
|
||||
with ``href`` and ``label`` keys, or button dictionaries using ``{"type":
|
||||
"button", "label": "...", "attrs": {...}}`` for JavaScript-backed interactions.
|
||||
Both shapes can include an optional ``description`` key. Custom
|
||||
``_action_menu.html`` templates should preserve support for both link and button
|
||||
action items.
|
||||
|
||||
.. _custom_pages:
|
||||
|
||||
Custom pages
|
||||
|
|
|
|||
|
|
@ -201,6 +201,15 @@ Search example with ``?q=facet`` returns only items matching ``.*facet.*``:
|
|||
|
||||
When multiple search terms are provided (e.g., ``?q=user+profile``), items must match the pattern ``.*user.*profile.*``. Results are ordered by relevance, then by item type and shortest display name.
|
||||
|
||||
.. _AutocompleteDebugView:
|
||||
|
||||
/-/debug/autocomplete
|
||||
---------------------
|
||||
|
||||
The debug tool at ``/-/debug/autocomplete`` can be used to try out the autocomplete component against a specific table. Pass ``?database=db&table=table`` to display an autocomplete field backed by that table's ``/-/autocomplete`` endpoint.
|
||||
|
||||
Without those query string arguments, the page lists up to five tables with detected label columns, scanning at most 100 tables.
|
||||
|
||||
.. _JsonDataView_threads:
|
||||
|
||||
/-/threads
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ The ``datasetteManager`` object
|
|||
``registerPlugin(name, implementation)``
|
||||
Call this to register a plugin, passing its name and implementation
|
||||
|
||||
``makeColumnField(context)``
|
||||
Calls the ``makeColumnField()`` hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.
|
||||
|
||||
``selectors`` - object
|
||||
An object providing named aliases to useful CSS selectors, :ref:`listed below <javascript_datasette_manager_selectors>`
|
||||
|
||||
|
|
@ -188,6 +191,285 @@ This example plugin adds two menu items - one to copy the column name to the cli
|
|||
});
|
||||
});
|
||||
|
||||
.. _javascript_plugins_makeColumnField:
|
||||
|
||||
makeColumnField(context)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
This method, if present, can provide a custom form field for a column in Datasette's row insert and edit dialogs.
|
||||
|
||||
It is designed for plugins that :ref:`register custom column types <plugin_register_column_types>` using the Python ``register_column_types()`` plugin hook. For example, a plugin that defines a ``file`` column type can use ``makeColumnField()`` to replace a plain text input with a file picker, and a plugin that defines a rich text column type can use it to enhance the field with an editor.
|
||||
|
||||
Datasette calls ``makeColumnField(context)`` on each registered JavaScript plugin when it renders an editable insert/edit field. Plugins should inspect the ``context`` object and only return a control object if they can handle that field. Otherwise, use a bare ``return;``.
|
||||
|
||||
The first plugin to return a truthy control object is used for that field. Plugins are called in registration order. If a plugin raises an exception, Datasette logs the error to the browser console and continues to the next plugin.
|
||||
|
||||
The row dialog tracks the value that will be sent to the insert/update API. The ``context`` object describes the column and form environment; custom controls should read and write field values using the ``field`` helper object passed to ``render(field)``.
|
||||
|
||||
Context object
|
||||
^^^^^^^^^^^^^^
|
||||
|
||||
``makeColumnField(context)`` is called with a context object describing the field. The current context object has these keys:
|
||||
|
||||
``mode`` - string
|
||||
``"insert"`` or ``"edit"``.
|
||||
|
||||
``database`` - string or null
|
||||
The database name.
|
||||
|
||||
``table`` - string or null
|
||||
The table name.
|
||||
|
||||
``tableUrl`` - string or null
|
||||
The path to the table page, including any configured :ref:`base URL prefix <setting_base_url>`.
|
||||
|
||||
``column`` - string
|
||||
The column name.
|
||||
|
||||
``columnType`` - object or null
|
||||
The configured Datasette column type for this column, if one exists. This is ``null`` if no column type has been configured.
|
||||
|
||||
If present, this object has exactly these keys:
|
||||
|
||||
``type`` - string
|
||||
The :ref:`registered column type name <plugin_register_column_types>`, matching the ``name`` attribute of the Python ``ColumnType`` subclass.
|
||||
|
||||
``config`` - object
|
||||
Configuration for this specific column type assignment. This is ``{}`` if no configuration has been set.
|
||||
|
||||
``sqliteType`` - string or null
|
||||
The SQLite affinity for this column, if known. This is one of ``"TEXT"``, ``"INTEGER"``, ``"REAL"``, ``"BLOB"``, ``"NUMERIC"`` or ``null`` if Datasette could not determine the affinity.
|
||||
|
||||
``notNull`` - boolean
|
||||
True if the column is defined as ``NOT NULL``.
|
||||
|
||||
``isPk`` - boolean
|
||||
True if this column is part of the table's primary key.
|
||||
|
||||
``defaultExpression`` - string or null
|
||||
The SQLite default expression for the column, if available. This is ``null`` if the column has no SQLite default. For example, a column defined with ``DEFAULT (datetime('now'))`` will have ``"datetime('now')"`` here. This is the expression from the table schema, not the actual value SQLite will insert.
|
||||
|
||||
``form`` - ``HTMLFormElement`` or null
|
||||
The row insert/edit form element.
|
||||
|
||||
``dialog`` - ``HTMLDialogElement`` or null
|
||||
The modal dialog element.
|
||||
|
||||
Returned control object
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
A plugin that wants to handle a field should return an object. Datasette currently recognizes these properties:
|
||||
|
||||
``useTextarea`` - boolean, optional
|
||||
If true, Datasette creates a ``<textarea>`` as the underlying ``field.input`` before calling ``render()``. If omitted, Datasette chooses either an ``<input>`` or ``<textarea>`` based on the column type and current value.
|
||||
|
||||
``render(field)`` - function
|
||||
Called once to render the custom field UI. ``field`` is a helper object described below.
|
||||
|
||||
The recommended pattern is to return a DOM node from ``render()``. Datasette appends that node to ``field.root``, a ``<div>`` inside the control area for that field in the row insert/edit dialog. A plugin can alternatively manipulate ``field.root`` directly and return nothing.
|
||||
|
||||
``focus(field)`` - function, optional
|
||||
Called when Datasette wants to focus this field, for example when focusing the first editable field in the dialog. Use this to focus the most useful interactive element inside the custom UI.
|
||||
|
||||
``destroy(field)`` - function, optional
|
||||
Called when Datasette tears down the insert/edit form. Use this to remove event listeners, close nested pickers, revoke object URLs, clear timers, or release other resources.
|
||||
|
||||
The field helper object
|
||||
^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The ``field`` object passed to ``render(field)``, ``focus(field)`` and ``destroy(field)`` provides stable IDs, DOM elements and value helpers for integrating with the row insert/edit dialog:
|
||||
|
||||
``context`` - object
|
||||
The original context object passed to ``makeColumnField()``.
|
||||
|
||||
``id`` - string
|
||||
The ID Datasette assigned to ``field.input``, the backing ``<input>`` or ``<textarea>`` element.
|
||||
|
||||
``labelId`` - string
|
||||
The ID of the visible field label.
|
||||
|
||||
``descriptionId`` - string
|
||||
The ID of the field metadata/help text. This metadata can include details such as ``Primary key``, ``Required``, ``Current value: NULL`` or ``Custom type: file``.
|
||||
|
||||
``root`` - ``HTMLElement``
|
||||
The empty ``<div>`` container created by Datasette for this custom field. It is inside the control area for the field in the row insert/edit dialog, next to the field label and above the field metadata. Datasette appends the DOM node returned by ``render(field)`` to this element. Plugins can alternatively manipulate this element directly and return nothing from ``render(field)``.
|
||||
|
||||
``input`` - ``HTMLInputElement`` or ``HTMLTextAreaElement``
|
||||
The core-owned backing form control. Plugins can keep this visible, wrap it or hide it, but should use the value helper methods below rather than mutating ``input.value`` directly.
|
||||
|
||||
``control``
|
||||
An alias for ``input``.
|
||||
|
||||
``meta`` - ``HTMLElement`` or null
|
||||
The field metadata/help text element.
|
||||
|
||||
``form`` - ``HTMLFormElement`` or null
|
||||
The containing row insert/edit form.
|
||||
|
||||
``dialog`` - ``HTMLDialogElement`` or null
|
||||
The containing modal dialog.
|
||||
|
||||
``getValue()`` - function
|
||||
Returns the current value for this field.
|
||||
|
||||
Datasette uses string values by default. Insert fields for ``"INTEGER"`` and ``"REAL"`` SQLite columns return numbers, or ``null`` if left blank. Plugins can use strings, numbers, booleans or ``null``. If a plugin is editing structured data stored in a SQLite ``TEXT`` column, such as JSON, it should serialize that data to a string before calling ``setValue()``.
|
||||
|
||||
``setValue(value)`` - function
|
||||
Sets the current value for this field. ``value`` should be a string, number, boolean or ``null``.
|
||||
|
||||
Calling ``setValue()`` also stops using the SQLite default for the field, if it was previously selected.
|
||||
|
||||
``getInitialValue()`` - function
|
||||
Returns the submitted-value representation the field had when the form was rendered. For edit forms this is the raw row value from the database. For insert forms this is the blank starting value.
|
||||
|
||||
``hasChanged()`` - function
|
||||
Returns true if the field has changed since Datasette last considered it unmodified. By default that means the field state when the insert/edit form was rendered.
|
||||
|
||||
``clearValue()`` - function
|
||||
Sets the value to ``null``.
|
||||
|
||||
``markClean()`` - function
|
||||
Tells Datasette to treat the field's current state as unmodified. After calling this method, ``hasChanged()`` returns false until the field value changes again or its SQLite-default state changes.
|
||||
|
||||
This is useful when a plugin rewrites the starting value into an equivalent representation while initializing its editor. For example, a rich text editor might normalize empty HTML or reserialize its initial document before the user has made any edits.
|
||||
|
||||
``isUsingSqliteDefault()`` - function
|
||||
Returns true if the insert dialog is currently set to omit this column and use the SQLite default.
|
||||
|
||||
``setValidity(message)`` - function
|
||||
Sets a custom validation message for this field, marks the backing input with ``aria-invalid="true"`` and shows the message in the field metadata area. Pass an empty string to clear the error.
|
||||
|
||||
``clearValidity()`` - function
|
||||
Clears any custom validation message previously set by ``setValidity()``.
|
||||
|
||||
Submitted value contract
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The ``field.setValue()`` method accepts the following value types:
|
||||
|
||||
* string
|
||||
* number
|
||||
* boolean
|
||||
* ``null``
|
||||
|
||||
These values are used as column values in requests to the :ref:`insert rows <TableInsertView>` and :ref:`update row <RowUpdateView>` JSON APIs.
|
||||
|
||||
Plugins should not pass objects or arrays to ``field.setValue()``. If a column stores structured data in SQLite, such as JSON in a ``TEXT`` column, the plugin should serialize that data first and submit the serialized string. Client-side parsing can still be useful for validation or editor state, but the submitted value should match the SQLite value Datasette should write.
|
||||
|
||||
Value helpers
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
Custom fields should use ``field.getValue()`` and ``field.setValue(value)`` for value handling:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
const currentValue = field.getValue();
|
||||
field.setValue("new value");
|
||||
field.setValue(null);
|
||||
|
||||
Plugins can keep the core input visible, wrap it in a custom element, or hide it and provide a richer interface. If the input is hidden, the custom UI must still expose an accessible name, state and keyboard interaction.
|
||||
|
||||
``field.setValue()`` updates both ``field.input`` and the value used in the insert/update request.
|
||||
|
||||
For example, a file picker that stores a selected file ID can hide the backing input and call ``field.setValue()`` when the selection changes:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
field.input.type = "hidden";
|
||||
field.setValue(fileId);
|
||||
|
||||
For insert forms with a SQLite default, ``field.isUsingSqliteDefault()`` indicates whether Datasette will omit that column from the insert payload. Calling ``field.setValue(value)`` automatically stops using the SQLite default.
|
||||
|
||||
Lazy loading large controls
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
The JavaScript file that registers ``makeColumnField()`` should be small. If the actual control is large, load it from inside ``render()`` using dynamic ``import()``. That way the heavier code is only downloaded after a user opens an insert/edit dialog containing a matching column type.
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
const editorUrl = new URL("./editor.js", import.meta.url).href;
|
||||
|
||||
document.addEventListener("datasette_init", function (event) {
|
||||
event.detail.registerPlugin("my-editor", {
|
||||
version: "0.1",
|
||||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "my-editor") {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
useTextarea: true,
|
||||
render(field) {
|
||||
import(editorUrl).then(function () {
|
||||
// Enhance field.input here.
|
||||
});
|
||||
return field.input;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Example: textarea-backed custom element
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
This example handles a ``markdown-editor`` column type by asking Datasette for a textarea and wrapping that textarea in a custom ``<my-markdown-editor>`` Web Component element:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener("datasette_init", function (event) {
|
||||
event.detail.registerPlugin("markdown-editor", {
|
||||
version: "0.1",
|
||||
|
||||
makeColumnField(context) {
|
||||
if (!context.columnType || context.columnType.type !== "markdown-editor") {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
useTextarea: true,
|
||||
|
||||
render(field) {
|
||||
const editor = document.createElement("my-markdown-editor");
|
||||
editor.appendChild(field.input);
|
||||
|
||||
if (field.labelId) {
|
||||
field.input.setAttribute("aria-labelledby", field.labelId);
|
||||
}
|
||||
if (field.descriptionId) {
|
||||
field.input.setAttribute("aria-describedby", field.descriptionId);
|
||||
}
|
||||
|
||||
return editor;
|
||||
},
|
||||
|
||||
focus(field) {
|
||||
const editor = field.root.querySelector("my-markdown-editor");
|
||||
if (editor && editor.focus) {
|
||||
editor.focus();
|
||||
} else {
|
||||
field.input.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Accessibility
|
||||
^^^^^^^^^^^^^
|
||||
|
||||
Custom fields are responsible for preserving the accessibility of the form:
|
||||
|
||||
- The visible field label should name the control. Use ``field.labelId`` with ``aria-labelledby`` when wrapping or replacing the visible input.
|
||||
- Field metadata should remain available to assistive technology. Use ``field.descriptionId`` with ``aria-describedby``.
|
||||
- Keyboard users must be able to operate every part of the custom field.
|
||||
- If the field opens an inline picker or other nested UI, ``Escape`` should close that nested UI first and return focus to a sensible element.
|
||||
- If a control performs asynchronous loading, expose loading and error states in the UI. Use appropriate ARIA live regions where the state change is important to understand the field.
|
||||
- If a plugin hides ``field.input``, the replacement UI must still make the current value and available actions clear.
|
||||
|
||||
Plugins should not submit the row themselves from inside ``makeColumnField()`` controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
|
||||
|
||||
.. _javascript_datasette_manager_selectors:
|
||||
|
||||
Selectors
|
||||
|
|
|
|||
|
|
@ -1201,6 +1201,48 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
|
||||
.. [[[end]]]
|
||||
|
||||
.. _TableAutocompleteView:
|
||||
|
||||
Table autocomplete
|
||||
------------------
|
||||
|
||||
The ``/<database>/<table>/-/autocomplete`` endpoint returns up to 10 primary key
|
||||
matches for a table, intended for building autocomplete interfaces such as
|
||||
foreign key pickers.
|
||||
|
||||
::
|
||||
|
||||
GET /<database>/<table>/-/autocomplete?q=search
|
||||
|
||||
The ``q`` parameter is required. If it is omitted or blank, the endpoint returns
|
||||
an empty ``"rows"`` list.
|
||||
|
||||
The response includes a ``"pks"`` object containing the primary key value or
|
||||
values for each row. If Datasette can detect a label column, or one has been
|
||||
configured using ``label_column``, each row will also include ``"label"``:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"rows": [
|
||||
{
|
||||
"pks": {
|
||||
"id": 1
|
||||
},
|
||||
"label": "Example row"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
The endpoint searches the primary key column or columns and the label column
|
||||
using escaped SQL ``LIKE`` queries. A single-column primary key exact match is
|
||||
returned first. Other matches are ordered by the shortest matching label value
|
||||
where a label column is available.
|
||||
|
||||
The initial search runs with a 500ms time limit. If that query times out,
|
||||
Datasette falls back to a prefix match against the first primary key column so
|
||||
SQLite can use the primary key index.
|
||||
|
||||
.. _table_arguments:
|
||||
|
||||
Table arguments
|
||||
|
|
|
|||
|
|
@ -118,6 +118,16 @@ Some examples:
|
|||
* `../antiquities-act%2Factions_under_antiquities_act <https://fivethirtyeight.datasettes.com/fivethirtyeight/antiquities-act%2Factions_under_antiquities_act>`_ is an interface for exploring the "actions under the antiquities act" data table published by FiveThirtyEight.
|
||||
* `../global-power-plants?country_long=United+Kingdom&primary_fuel=Gas <https://datasette.io/global-power-plants/global-power-plants?_facet=primary_fuel&_facet=owner&_facet=country_long&country_long__exact=United+Kingdom&primary_fuel=Gas>`_ is a filtered table page showing every Gas power plant in the United Kingdom. It includes some default facets (configured using `its metadata.json <https://datasette.io/-/metadata>`_) and uses the `datasette-cluster-map <https://github.com/simonw/datasette-cluster-map>`_ plugin to show a map of the results.
|
||||
|
||||
.. _TableFragmentView:
|
||||
|
||||
Table fragment
|
||||
--------------
|
||||
|
||||
The ``/<database>/<table>/-/fragment`` endpoint returns the rendered table HTML
|
||||
for rows matching the provided filters. It is used by Datasette's row editing
|
||||
interface to refresh rows after changes while still respecting custom table
|
||||
templates and ``render_cell`` plugin hooks.
|
||||
|
||||
.. _RowView:
|
||||
|
||||
Row
|
||||
|
|
|
|||
|
|
@ -1092,7 +1092,7 @@ Column types are assigned to columns via the :ref:`column_types <table_configura
|
|||
config:
|
||||
format: rgb
|
||||
|
||||
Datasette includes three built-in column types: ``url``, ``email``, and ``json``.
|
||||
Datasette includes four built-in column types: ``url``, ``email``, ``json``, and ``textarea``. The ``textarea`` type is an editing hint that causes Datasette's insert/edit forms to use a multiline ``<textarea>`` control for that column.
|
||||
|
||||
.. _plugin_asgi_wrapper:
|
||||
|
||||
|
|
@ -1909,7 +1909,80 @@ Action hooks
|
|||
|
||||
Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing.
|
||||
|
||||
Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail.
|
||||
Each of these hooks should return a list of menu items, with optional ``"description": "..."`` keys describing each action in more detail.
|
||||
|
||||
The most common action item is a link to another page:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
"href": datasette.urls.path("/-/custom-action"),
|
||||
"label": "Custom action",
|
||||
"description": "Run this action on a separate page.",
|
||||
}
|
||||
|
||||
Plugins can also return button actions for JavaScript-backed interactions:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Open custom dialog",
|
||||
"description": "Show a dialog without leaving this page.",
|
||||
"attrs": {
|
||||
"aria-label": "Open custom dialog",
|
||||
"data-plugin-action": "open-custom-dialog",
|
||||
},
|
||||
}
|
||||
|
||||
These are rendered as ``<button type="button" class="button-as-link action-menu-button" role="menuitem" tabindex="-1">``. The optional ``attrs`` dictionary is added to the button, and is useful for ``data-*`` attributes that your plugin's JavaScript can use to attach event handlers.
|
||||
|
||||
Here is a minimal plugin example that adds a button to a table page and loads JavaScript to handle clicks on that button:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from datasette import hookimpl
|
||||
|
||||
|
||||
@hookimpl
|
||||
def table_actions(datasette, database, table):
|
||||
return [
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Show table name",
|
||||
"description": "Open a JavaScript-powered plugin action.",
|
||||
"attrs": {
|
||||
"aria-label": "Show table name",
|
||||
"data-plugin-action": "show-table-name",
|
||||
"data-database": database,
|
||||
"data-table": table,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@hookimpl
|
||||
def extra_js_urls(datasette):
|
||||
return [
|
||||
datasette.urls.static_plugins(
|
||||
"datasette_show_table",
|
||||
"show-table.js",
|
||||
)
|
||||
]
|
||||
|
||||
The ``static/show-table.js`` file in that plugin could look like this:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest(
|
||||
"button[data-plugin-action='show-table-name']"
|
||||
);
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
alert(`${button.dataset.database}.${button.dataset.table}`);
|
||||
});
|
||||
|
||||
They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items.
|
||||
|
||||
|
|
|
|||
|
|
@ -357,15 +357,30 @@ def menu_links(datasette, actor, request):
|
|||
|
||||
|
||||
@hookimpl
|
||||
def table_actions(datasette, database, table, actor):
|
||||
def table_actions(datasette, database, table, actor, request):
|
||||
if actor:
|
||||
return [
|
||||
actions = [
|
||||
{
|
||||
"href": datasette.urls.instance(),
|
||||
"label": f"Database: {database}",
|
||||
},
|
||||
{"href": datasette.urls.instance(), "label": f"Table: {table}"},
|
||||
]
|
||||
if request.args.get("_button"):
|
||||
actions.append(
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Plugin button",
|
||||
"description": "Runs JavaScript from a plugin",
|
||||
"attrs": {
|
||||
"aria-label": "Plugin button for {}".format(table),
|
||||
"data-plugin-action": "plugin-button",
|
||||
"data-database": database,
|
||||
"data-table": table,
|
||||
},
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
@hookimpl
|
||||
|
|
|
|||
236
tests/test_autocomplete.py
Normal file
236
tests/test_autocomplete.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import pytest
|
||||
|
||||
from datasette.app import Datasette
|
||||
import datasette.views.table as table_views
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_single_pk_exact_match_and_label_order():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("autocomplete_single")
|
||||
await db.execute_write_script("""
|
||||
create table people (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
insert into people (id, name) values
|
||||
(2, 'Longer non-label pk match'),
|
||||
(20, '2'),
|
||||
(21, '22'),
|
||||
(200, 'A'),
|
||||
(3, 'A label containing 2');
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/autocomplete_single/people/-/autocomplete?q=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"id": 2}, "label": "Longer non-label pk match"},
|
||||
{"pks": {"id": 20}, "label": "2"},
|
||||
{"pks": {"id": 21}, "label": "22"},
|
||||
{"pks": {"id": 3}, "label": "A label containing 2"},
|
||||
{"pks": {"id": 200}, "label": "A"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_blank_q_returns_no_results():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("autocomplete_blank")
|
||||
await db.execute_write_script("""
|
||||
create table people (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
insert into people (id, name) values
|
||||
(1, 'Alice'),
|
||||
(2, 'Bob');
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete?q=")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"rows": []}
|
||||
|
||||
response = await ds.client.get("/autocomplete_blank/people/-/autocomplete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"rows": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_initial_returns_latest_rows():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("autocomplete_initial")
|
||||
await db.execute_write_script("""
|
||||
create table people (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
insert into people (id, name) values
|
||||
(1, 'Alice'),
|
||||
(2, 'Bob'),
|
||||
(3, 'Cleo');
|
||||
""")
|
||||
|
||||
response = await ds.client.get(
|
||||
"/autocomplete_initial/people/-/autocomplete?_initial=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"id": 3}, "label": "Cleo"},
|
||||
{"pks": {"id": 2}, "label": "Bob"},
|
||||
{"pks": {"id": 1}, "label": "Alice"},
|
||||
]
|
||||
}
|
||||
|
||||
response = await ds.client.get(
|
||||
"/autocomplete_initial/people/-/autocomplete?q=&_initial=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"id": 3}, "label": "Cleo"},
|
||||
{"pks": {"id": 2}, "label": "Bob"},
|
||||
{"pks": {"id": 1}, "label": "Alice"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_escapes_like_characters():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("autocomplete_escape")
|
||||
await db.execute_write_script("""
|
||||
create table tags (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
insert into tags (id, name) values
|
||||
(1, '100% real'),
|
||||
(2, '100X real'),
|
||||
(3, '100 percent real');
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/autocomplete_escape/tags/-/autocomplete?q=100%25")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"id": 1}, "label": "100% real"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_compound_pk_searches_all_pk_columns():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_memory_database("autocomplete_compound")
|
||||
await db.execute_write_script("""
|
||||
create table places (
|
||||
country text,
|
||||
code text,
|
||||
name text,
|
||||
primary key (country, code)
|
||||
);
|
||||
insert into places (country, code, name) values
|
||||
('us', 'ca', 'California'),
|
||||
('ca', 'bc', 'British Columbia'),
|
||||
('mx', 'ca', 'Campeche'),
|
||||
('zz', 'zz', 'Nothing');
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/autocomplete_compound/places/-/autocomplete?q=ca")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"country": "mx", "code": "ca"}, "label": "Campeche"},
|
||||
{"pks": {"country": "us", "code": "ca"}, "label": "California"},
|
||||
{"pks": {"country": "ca", "code": "bc"}, "label": "British Columbia"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_primary_key_called_label():
|
||||
ds = Datasette(
|
||||
memory=True,
|
||||
config={
|
||||
"databases": {
|
||||
"autocomplete_label_pk": {
|
||||
"tables": {"things": {"label_column": "name"}}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
db = ds.add_memory_database("autocomplete_label_pk")
|
||||
await db.execute_write_script("""
|
||||
create table things (
|
||||
label text primary key,
|
||||
name text
|
||||
);
|
||||
insert into things (label, name) values
|
||||
('abc', 'Display value'),
|
||||
('def', 'Other value');
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/autocomplete_label_pk/things/-/autocomplete?q=abc")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"rows": [
|
||||
{"pks": {"label": "abc"}, "label": "Display value"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_autocomplete_timeout_uses_prefix_fallback(monkeypatch):
|
||||
monkeypatch.setattr(table_views, "AUTOCOMPLETE_TIME_LIMIT_MS", 1)
|
||||
ds = Datasette(
|
||||
memory=True,
|
||||
config={
|
||||
"databases": {
|
||||
"autocomplete_timeout": {"tables": {"things": {"label_column": "name"}}}
|
||||
}
|
||||
},
|
||||
settings={
|
||||
"num_sql_threads": 1,
|
||||
},
|
||||
)
|
||||
db = ds.add_memory_database("autocomplete_timeout")
|
||||
await db.execute_write_script("""
|
||||
create table things (
|
||||
id text primary key,
|
||||
name text
|
||||
);
|
||||
insert into things (id, name) values
|
||||
('other-000001', 'item-1999 label-only match');
|
||||
""")
|
||||
|
||||
def insert_rows(conn):
|
||||
conn.executemany(
|
||||
"insert into things (id, name) values (?, ?)",
|
||||
((f"item-{i:06d}", f"name {i:06d}") for i in range(200_000)),
|
||||
)
|
||||
|
||||
await db.execute_write_fn(insert_rows)
|
||||
|
||||
response = await ds.client.get(
|
||||
"/autocomplete_timeout/things/-/autocomplete?q=item-1999"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data == {
|
||||
"rows": [
|
||||
{"pks": {"id": f"item-1999{i:02d}"}, "label": f"name 1999{i:02d}"}
|
||||
for i in range(10)
|
||||
]
|
||||
}
|
||||
|
|
@ -494,6 +494,7 @@ async def test_builtin_column_types_registered(ds_ct):
|
|||
assert "url" in ds_ct._column_types
|
||||
assert "email" in ds_ct._column_types
|
||||
assert "json" in ds_ct._column_types
|
||||
assert "textarea" in ds_ct._column_types
|
||||
assert "nonexistent" not in ds_ct._column_types
|
||||
|
||||
|
||||
|
|
@ -510,16 +511,25 @@ async def test_column_type_class_attributes(ds_ct):
|
|||
assert email_cls.sqlite_types == (SQLiteType.TEXT,)
|
||||
json_cls = ds_ct._column_types["json"]
|
||||
assert json_cls.sqlite_types == (SQLiteType.TEXT,)
|
||||
textarea_cls = ds_ct._column_types["textarea"]
|
||||
assert textarea_cls.name == "textarea"
|
||||
assert textarea_cls.description == "Multiline text"
|
||||
assert textarea_cls.sqlite_types == (SQLiteType.TEXT,)
|
||||
|
||||
|
||||
def test_sqlite_type_from_declared_type():
|
||||
assert SQLiteType.from_declared_type(None) == SQLiteType.BLOB
|
||||
assert SQLiteType.from_declared_type("text") == SQLiteType.TEXT
|
||||
assert SQLiteType.from_declared_type("varchar(255)") == SQLiteType.TEXT
|
||||
assert SQLiteType.from_declared_type("integer") == SQLiteType.INTEGER
|
||||
assert SQLiteType.from_declared_type("float") == SQLiteType.REAL
|
||||
assert SQLiteType.from_declared_type("blob") == SQLiteType.BLOB
|
||||
assert SQLiteType.from_declared_type("") == SQLiteType.NULL
|
||||
assert SQLiteType.from_declared_type("numeric") is None
|
||||
assert SQLiteType.from_declared_type("") == SQLiteType.BLOB
|
||||
assert SQLiteType.from_declared_type("numeric") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("decimal(10,5)") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("boolean") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("date") == SQLiteType.NUMERIC
|
||||
assert SQLiteType.from_declared_type("null") == SQLiteType.NUMERIC
|
||||
|
||||
|
||||
# --- JSON API ---
|
||||
|
|
@ -941,6 +951,7 @@ async def test_set_column_type_ui_data_includes_applicable_types(
|
|||
"options": [
|
||||
{"name": "email", "description": "Email address"},
|
||||
{"name": "json", "description": "JSON data"},
|
||||
{"name": "textarea", "description": "Multiline text"},
|
||||
{"name": "url", "description": "URL"},
|
||||
],
|
||||
}
|
||||
|
|
@ -949,6 +960,7 @@ async def test_set_column_type_ui_data_includes_applicable_types(
|
|||
"options": [
|
||||
{"name": "email", "description": "Email address"},
|
||||
{"name": "json", "description": "JSON data"},
|
||||
{"name": "textarea", "description": "Multiline text"},
|
||||
{"name": "url", "description": "URL"},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
659
tests/test_datasette_manager_js.py
Normal file
659
tests/test_datasette_manager_js.py
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[1] / "datasette" / "static"
|
||||
|
||||
|
||||
def test_datasette_manager_make_column_field():
|
||||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const datasetteManagerJs = __DATASETTE_MANAGER_JS__;
|
||||
|
||||
const documentListeners = {};
|
||||
global.CustomEvent = class {
|
||||
constructor(name, options) {
|
||||
this.type = name;
|
||||
this.detail = options ? options.detail : undefined;
|
||||
}
|
||||
};
|
||||
global.document = {
|
||||
addEventListener(name, callback) {
|
||||
documentListeners[name] = documentListeners[name] || [];
|
||||
documentListeners[name].push(callback);
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
for (const callback of documentListeners[event.type] || []) {
|
||||
callback(event);
|
||||
}
|
||||
},
|
||||
};
|
||||
global.window = { datasetteVersion: "test" };
|
||||
|
||||
vm.runInThisContext(
|
||||
fs.readFileSync(datasetteManagerJs, "utf8"),
|
||||
{ filename: "datasette-manager.js" }
|
||||
);
|
||||
for (const callback of documentListeners.DOMContentLoaded || []) {
|
||||
callback();
|
||||
}
|
||||
|
||||
window.__DATASETTE__.registerPlugin("declines", {
|
||||
makeColumnField() {
|
||||
return;
|
||||
},
|
||||
});
|
||||
window.__DATASETTE__.registerPlugin("handles", {
|
||||
makeColumnField(context) {
|
||||
if (context.columnType.type !== "demo") {
|
||||
return;
|
||||
}
|
||||
return { useTextarea: true };
|
||||
},
|
||||
});
|
||||
|
||||
const control = window.__DATASETTE__.makeColumnField({
|
||||
column: "body",
|
||||
columnType: { type: "demo", config: null },
|
||||
});
|
||||
console.log(JSON.stringify(control));
|
||||
""").replace(
|
||||
"__DATASETTE_MANAGER_JS__",
|
||||
json.dumps(str(STATIC_DIR / "datasette-manager.js")),
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"pluginName": "handles",
|
||||
"useTextarea": True,
|
||||
}
|
||||
|
||||
|
||||
def test_table_plugin_column_field_api():
|
||||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const editToolsJs = __EDIT_TOOLS_JS__;
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, options) {
|
||||
this.type = type;
|
||||
this.bubbles = !!(options && options.bubbles);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = "div") {
|
||||
this.nodeName = tagName.toUpperCase();
|
||||
this.nodeType = 1;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this.value = "";
|
||||
this.name = "";
|
||||
this.disabled = false;
|
||||
this.readOnly = false;
|
||||
this.dispatchedEvents = [];
|
||||
this.eventListeners = {};
|
||||
this.validationMessage = "";
|
||||
this.hidden = false;
|
||||
this.textContent = "";
|
||||
this.className = "";
|
||||
this.classList = {
|
||||
add: (...names) => {
|
||||
const classes = new Set(this.className.split(/\\s+/).filter(Boolean));
|
||||
for (const name of names) {
|
||||
classes.add(name);
|
||||
}
|
||||
this.className = Array.from(classes).join(" ");
|
||||
},
|
||||
remove: (...names) => {
|
||||
const removeNames = new Set(names);
|
||||
this.className = this.className
|
||||
.split(/\\s+/)
|
||||
.filter((name) => name && !removeNames.has(name))
|
||||
.join(" ");
|
||||
},
|
||||
contains: (name) => this.className.split(/\\s+/).includes(name),
|
||||
};
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
addEventListener(type, callback) {
|
||||
this.eventListeners[type] = this.eventListeners[type] || [];
|
||||
this.eventListeners[type].push(callback);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
event.target = event.target || this;
|
||||
this.dispatchedEvents.push(event.type);
|
||||
for (const callback of this.eventListeners[event.type] || []) {
|
||||
callback(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] || null;
|
||||
}
|
||||
removeAttribute(name) {
|
||||
delete this.attributes[name];
|
||||
}
|
||||
setCustomValidity(message) {
|
||||
this.validationMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
global.Event = FakeEvent;
|
||||
global.document = {
|
||||
addEventListener() {},
|
||||
createElement(tagName) {
|
||||
return new FakeElement(tagName);
|
||||
},
|
||||
createTextNode(text) {
|
||||
const node = new FakeElement("#text");
|
||||
node.textContent = text;
|
||||
return node;
|
||||
},
|
||||
};
|
||||
global.location = {
|
||||
href: "http://localhost/data/projects",
|
||||
pathname: "/data/projects",
|
||||
search: "",
|
||||
};
|
||||
global.window = {
|
||||
_datasetteTableData: {
|
||||
database: "data",
|
||||
table: "projects",
|
||||
tableUrl: "/data/projects",
|
||||
},
|
||||
};
|
||||
|
||||
vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), {
|
||||
filename: "edit-tools.js",
|
||||
});
|
||||
|
||||
const context = columnFormControlContext(
|
||||
"logo",
|
||||
true,
|
||||
{ type: "file", config: null },
|
||||
{
|
||||
mode: "edit",
|
||||
defaultExpression: "lower(hex(randomblob(4)))",
|
||||
useSqliteDefault: true,
|
||||
}
|
||||
);
|
||||
const expectedContextKeys = [
|
||||
"mode",
|
||||
"database",
|
||||
"table",
|
||||
"tableUrl",
|
||||
"column",
|
||||
"columnType",
|
||||
"sqliteType",
|
||||
"notNull",
|
||||
"isPk",
|
||||
"defaultExpression",
|
||||
"form",
|
||||
"dialog",
|
||||
].join(",");
|
||||
if (Object.keys(context).join(",") !== expectedContextKeys) {
|
||||
throw new Error(`Unexpected context keys: ${Object.keys(context).join(",")}`);
|
||||
}
|
||||
if (context.defaultExpression !== "lower(hex(randomblob(4)))") {
|
||||
throw new Error("context.defaultExpression was not set");
|
||||
}
|
||||
if (JSON.stringify(context.columnType) !== '{"type":"file","config":{}}') {
|
||||
throw new Error("context.columnType should expose type and object config");
|
||||
}
|
||||
if (!context.isPk) {
|
||||
throw new Error("context.isPk should say whether the column is a primary key");
|
||||
}
|
||||
|
||||
const control = new FakeElement("input");
|
||||
control.name = "logo";
|
||||
control.value = "df-old";
|
||||
control.dataset.initialValue = "df-old";
|
||||
control.dataset.initialValueKind = "string";
|
||||
control.dataset.currentValueKind = "string";
|
||||
control.dataset.useSqliteDefault = "1";
|
||||
control.disabled = true;
|
||||
|
||||
const field = createColumnFieldApi({
|
||||
id: "row-edit-field-0",
|
||||
labelId: "row-edit-field-label-0",
|
||||
descriptionId: "row-edit-field-meta-0",
|
||||
control,
|
||||
meta: new FakeElement("span"),
|
||||
context,
|
||||
});
|
||||
|
||||
let renderArgumentCount = null;
|
||||
let renderField = null;
|
||||
const wrapper = renderColumnField(
|
||||
{
|
||||
pluginName: "test-plugin",
|
||||
render(field) {
|
||||
renderArgumentCount = arguments.length;
|
||||
renderField = field;
|
||||
return document.createElement("button");
|
||||
},
|
||||
},
|
||||
field
|
||||
);
|
||||
if (renderArgumentCount !== 1 || renderField !== field) {
|
||||
throw new Error("plugin render should receive the field object only");
|
||||
}
|
||||
if (field.root !== wrapper) {
|
||||
throw new Error("field.root should be the plugin wrapper");
|
||||
}
|
||||
if (wrapper.children.length !== 1 || wrapper.children[0].nodeName !== "BUTTON") {
|
||||
throw new Error("plugin render should append returned DOM nodes to field.root");
|
||||
}
|
||||
|
||||
field.setValue(null);
|
||||
if (field.getValue() !== null) {
|
||||
throw new Error("field.setValue(null) should round-trip as null");
|
||||
}
|
||||
if (field.isUsingSqliteDefault()) {
|
||||
throw new Error("field.setValue() should stop using the SQLite default");
|
||||
}
|
||||
if (control.dataset.currentValueKind !== "null") {
|
||||
throw new Error("null values should update currentValueKind");
|
||||
}
|
||||
|
||||
field.setValue("df-new");
|
||||
if (field.getValue() !== "df-new") {
|
||||
throw new Error("field.setValue() should update the current value");
|
||||
}
|
||||
if (field.getInitialValue() !== "df-old") {
|
||||
throw new Error("field.getInitialValue() should remain stable");
|
||||
}
|
||||
if (!field.hasChanged()) {
|
||||
throw new Error("field.hasChanged() should notice plugin value changes");
|
||||
}
|
||||
if (control.dispatchedEvents.length !== 0) {
|
||||
throw new Error(`field.setValue() should not dispatch events: ${control.dispatchedEvents}`);
|
||||
}
|
||||
|
||||
const dirtyRowField = new FakeElement("div");
|
||||
dirtyRowField._datasetteColumnFormField = field;
|
||||
const dirtyState = {
|
||||
hasLoaded: true,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
mode: "edit",
|
||||
fields: {
|
||||
querySelectorAll(selector) {
|
||||
return selector === ".row-edit-field" ? [dirtyRowField] : [];
|
||||
},
|
||||
},
|
||||
dialog: {
|
||||
closeCalled: false,
|
||||
close() {
|
||||
this.closeCalled = true;
|
||||
},
|
||||
},
|
||||
shouldRestoreFocus: false,
|
||||
};
|
||||
const confirmMessages = [];
|
||||
window.confirm = (message) => {
|
||||
confirmMessages.push(message);
|
||||
return false;
|
||||
};
|
||||
if (!rowEditDialogHasChanges(dirtyState)) {
|
||||
throw new Error("row edit dialog should notice changed field values");
|
||||
}
|
||||
if (closeRowEditDialogIfConfirmed(dirtyState)) {
|
||||
throw new Error("dirty row edit dialog should stay open when discard is rejected");
|
||||
}
|
||||
if (dirtyState.dialog.closeCalled) {
|
||||
throw new Error("dirty row edit dialog should not close when discard is rejected");
|
||||
}
|
||||
if (confirmMessages[0] !== "Discard unsaved changes to this row?") {
|
||||
throw new Error(`Unexpected discard confirmation: ${confirmMessages[0]}`);
|
||||
}
|
||||
dirtyState.mode = "insert";
|
||||
window.confirm = (message) => {
|
||||
confirmMessages.push(message);
|
||||
return true;
|
||||
};
|
||||
if (!closeRowEditDialogIfConfirmed(dirtyState)) {
|
||||
throw new Error("dirty row edit dialog should close when discard is confirmed");
|
||||
}
|
||||
if (!dirtyState.dialog.closeCalled || !dirtyState.shouldRestoreFocus) {
|
||||
throw new Error("confirmed dirty row edit dialog should close and restore focus");
|
||||
}
|
||||
if (confirmMessages[1] !== "Discard this new row?") {
|
||||
throw new Error(`Unexpected insert discard confirmation: ${confirmMessages[1]}`);
|
||||
}
|
||||
|
||||
const cleanContext = columnFormControlContext(
|
||||
"title",
|
||||
false,
|
||||
null,
|
||||
{ mode: "edit" }
|
||||
);
|
||||
if (cleanContext.defaultExpression !== null) {
|
||||
throw new Error("context.defaultExpression should be null without a SQLite default");
|
||||
}
|
||||
const cleanControl = new FakeElement("input");
|
||||
cleanControl.name = "title";
|
||||
cleanControl.value = "clean";
|
||||
cleanControl.dataset.initialValue = "clean";
|
||||
cleanControl.dataset.initialValueKind = "string";
|
||||
cleanControl.dataset.currentValueKind = "string";
|
||||
const cleanField = createColumnFieldApi({
|
||||
id: "row-edit-field-1",
|
||||
labelId: "row-edit-field-label-1",
|
||||
descriptionId: "row-edit-field-meta-1",
|
||||
control: cleanControl,
|
||||
meta: new FakeElement("span"),
|
||||
context: cleanContext,
|
||||
});
|
||||
const cleanRowField = new FakeElement("div");
|
||||
cleanRowField._datasetteColumnFormField = cleanField;
|
||||
const cleanState = {
|
||||
hasLoaded: true,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
mode: "edit",
|
||||
fields: {
|
||||
querySelectorAll(selector) {
|
||||
return selector === ".row-edit-field" ? [cleanRowField] : [];
|
||||
},
|
||||
},
|
||||
dialog: {
|
||||
closeCalled: false,
|
||||
close() {
|
||||
this.closeCalled = true;
|
||||
},
|
||||
},
|
||||
shouldRestoreFocus: false,
|
||||
};
|
||||
confirmMessages.length = 0;
|
||||
window.confirm = (message) => {
|
||||
confirmMessages.push(message);
|
||||
return false;
|
||||
};
|
||||
if (rowEditDialogHasChanges(cleanState)) {
|
||||
throw new Error("row edit dialog should ignore unchanged field values");
|
||||
}
|
||||
if (!closeRowEditDialogIfConfirmed(cleanState)) {
|
||||
throw new Error("clean row edit dialog should close without confirmation");
|
||||
}
|
||||
if (!cleanState.dialog.closeCalled || !cleanState.shouldRestoreFocus) {
|
||||
throw new Error("clean row edit dialog should close and restore focus");
|
||||
}
|
||||
if (confirmMessages.length !== 0) {
|
||||
throw new Error("clean row edit dialog should not ask for confirmation");
|
||||
}
|
||||
|
||||
dirtyState.dialog.closeCalled = false;
|
||||
dirtyState.shouldRestoreFocus = false;
|
||||
confirmMessages.length = 0;
|
||||
field.setValue("<p></p>");
|
||||
field.markClean();
|
||||
if (field.hasChanged()) {
|
||||
throw new Error("field.markClean() should update the clean baseline");
|
||||
}
|
||||
if (rowEditDialogHasChanges(dirtyState)) {
|
||||
throw new Error("row edit dialog should ignore clean plugin normalization");
|
||||
}
|
||||
if (!closeRowEditDialogIfConfirmed(dirtyState)) {
|
||||
throw new Error("normalized row edit dialog should close without confirmation");
|
||||
}
|
||||
if (confirmMessages.length !== 0) {
|
||||
throw new Error("normalized row edit dialog should not ask for confirmation");
|
||||
}
|
||||
field.setValue("<p>Hello</p>");
|
||||
if (!field.hasChanged() || !rowEditDialogHasChanges(dirtyState)) {
|
||||
throw new Error("later plugin value changes should still count as dirty");
|
||||
}
|
||||
|
||||
try {
|
||||
field.setValue({ id: "df-object" });
|
||||
throw new Error("field.setValue() should reject object values");
|
||||
} catch (error) {
|
||||
if (!String(error.message).includes("serialize objects")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
field.setValidity("Pick a file");
|
||||
if (control.validationMessage !== "Pick a file") {
|
||||
throw new Error("field.setValidity() should set custom validity");
|
||||
}
|
||||
if (control.getAttribute("aria-invalid") !== "true") {
|
||||
throw new Error("field.setValidity() should set aria-invalid");
|
||||
}
|
||||
if (!field.validationMessageElement || field.validationMessageElement.hidden) {
|
||||
throw new Error("field.setValidity() should show a field validation message");
|
||||
}
|
||||
field.clearValidity();
|
||||
if (control.validationMessage !== "" || control.getAttribute("aria-invalid") !== null) {
|
||||
throw new Error("field.clearValidity() should clear custom validity");
|
||||
}
|
||||
|
||||
field.useSqliteDefault();
|
||||
if (!field.isUsingSqliteDefault() || !control.disabled) {
|
||||
throw new Error("field.useSqliteDefault() should mark and disable control");
|
||||
}
|
||||
|
||||
process.stdout.write("ok");
|
||||
""").replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js")))
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == "ok"
|
||||
|
||||
|
||||
def test_builtin_json_column_field_validation():
|
||||
script = textwrap.dedent("""
|
||||
const fs = require("fs");
|
||||
const vm = require("vm");
|
||||
const editToolsJs = __EDIT_TOOLS_JS__;
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, options) {
|
||||
this.type = type;
|
||||
this.bubbles = !!(options && options.bubbles);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = "div") {
|
||||
this.nodeName = tagName.toUpperCase();
|
||||
this.nodeType = 1;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this.value = "";
|
||||
this.name = "";
|
||||
this.disabled = false;
|
||||
this.hidden = false;
|
||||
this.textContent = "";
|
||||
this.validationMessage = "";
|
||||
this.eventListeners = {};
|
||||
this.className = "";
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
addEventListener(type, callback) {
|
||||
this.eventListeners[type] = this.eventListeners[type] || [];
|
||||
this.eventListeners[type].push(callback);
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
event.target = event.target || this;
|
||||
for (const callback of this.eventListeners[event.type] || []) {
|
||||
callback(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes[name] || null;
|
||||
}
|
||||
removeAttribute(name) {
|
||||
delete this.attributes[name];
|
||||
}
|
||||
setCustomValidity(message) {
|
||||
this.validationMessage = message;
|
||||
}
|
||||
}
|
||||
|
||||
global.Event = FakeEvent;
|
||||
global.document = {
|
||||
addEventListener() {},
|
||||
createElement(tagName) {
|
||||
return new FakeElement(tagName);
|
||||
},
|
||||
createTextNode(text) {
|
||||
const node = new FakeElement("#text");
|
||||
node.textContent = text;
|
||||
return node;
|
||||
},
|
||||
};
|
||||
global.location = {
|
||||
href: "http://localhost/data/projects",
|
||||
pathname: "/data/projects",
|
||||
search: "",
|
||||
};
|
||||
global.window = {
|
||||
_datasetteTableData: {
|
||||
database: "data",
|
||||
table: "projects",
|
||||
tableUrl: "/data/projects",
|
||||
},
|
||||
};
|
||||
|
||||
vm.runInThisContext(fs.readFileSync(editToolsJs, "utf8"), {
|
||||
filename: "edit-tools.js",
|
||||
});
|
||||
|
||||
const plugins = [];
|
||||
registerBuiltinColumnFieldPlugins({
|
||||
registerPlugin(name, plugin) {
|
||||
plugins.push({ name, plugin });
|
||||
},
|
||||
});
|
||||
const jsonPlugin = plugins.find((entry) => entry.name === "datasette-json-column");
|
||||
if (!jsonPlugin) {
|
||||
throw new Error("datasette-json-column plugin was not registered");
|
||||
}
|
||||
const pluginControl = jsonPlugin.plugin.makeColumnField({
|
||||
column: "metadata",
|
||||
columnType: { type: "json", config: {} },
|
||||
});
|
||||
if (!pluginControl || pluginControl.useTextarea !== true) {
|
||||
throw new Error("JSON column plugin should request a textarea");
|
||||
}
|
||||
|
||||
const context = columnFormControlContext(
|
||||
"metadata",
|
||||
false,
|
||||
{ type: "json", config: {} },
|
||||
{ mode: "edit" }
|
||||
);
|
||||
const control = new FakeElement("textarea");
|
||||
control.name = "metadata";
|
||||
control.value = '{"ok": true}';
|
||||
control.dataset.initialValue = '{"ok": true}';
|
||||
control.dataset.initialValueKind = "string";
|
||||
control.dataset.currentValueKind = "string";
|
||||
const meta = new FakeElement("span");
|
||||
|
||||
const field = createColumnFieldApi({
|
||||
id: "row-edit-field-0",
|
||||
labelId: "row-edit-field-label-0",
|
||||
descriptionId: "row-edit-field-meta-0",
|
||||
control,
|
||||
meta,
|
||||
context,
|
||||
});
|
||||
renderColumnField(
|
||||
Object.assign({ pluginName: "datasette-json-column" }, pluginControl),
|
||||
field
|
||||
);
|
||||
|
||||
if (control.validationMessage !== "") {
|
||||
throw new Error("Initial valid JSON should not be invalid");
|
||||
}
|
||||
if (control.dataset.initialValueKind !== "string") {
|
||||
throw new Error("JSON plugin should keep the original string value kind");
|
||||
}
|
||||
if (control.dataset.currentValueKind !== "string") {
|
||||
throw new Error("JSON plugin should keep the current string value kind");
|
||||
}
|
||||
if (!field.validationMessageElement || field.validationMessageElement.hidden !== true) {
|
||||
throw new Error("JSON validation message should start hidden");
|
||||
}
|
||||
|
||||
control.value = "{";
|
||||
control.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
if (!control.validationMessage.startsWith("Invalid JSON")) {
|
||||
throw new Error("Invalid JSON should set a custom validity message");
|
||||
}
|
||||
if (control.getAttribute("aria-invalid") !== "true") {
|
||||
throw new Error("Invalid JSON should set aria-invalid");
|
||||
}
|
||||
if (field.validationMessageElement.hidden) {
|
||||
throw new Error("Invalid JSON should show the validation message");
|
||||
}
|
||||
|
||||
control.value = '{"ok": true}';
|
||||
control.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
if (control.validationMessage !== "") {
|
||||
throw new Error("Valid JSON should clear the custom validity message");
|
||||
}
|
||||
if (control.getAttribute("aria-invalid") !== null) {
|
||||
throw new Error("Valid JSON should clear aria-invalid");
|
||||
}
|
||||
if (!field.validationMessageElement.hidden) {
|
||||
throw new Error("Valid JSON should hide the validation message");
|
||||
}
|
||||
|
||||
control.dataset.initialValue = '{"ok":';
|
||||
control.value = '{"ok": true}';
|
||||
const values = collectRowFormValues({
|
||||
mode: "edit",
|
||||
fields: {
|
||||
querySelectorAll() {
|
||||
return [control];
|
||||
},
|
||||
},
|
||||
});
|
||||
if (values.metadata !== '{"ok": true}') {
|
||||
throw new Error("Corrected JSON should be submitted as a string value");
|
||||
}
|
||||
|
||||
process.stdout.write("ok");
|
||||
""").replace("__EDIT_TOOLS_JS__", json.dumps(str(STATIC_DIR / "edit-tools.js")))
|
||||
result = subprocess.run(
|
||||
["node", "-e", script],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == "ok"
|
||||
91
tests/test_debug_autocomplete.py
Normal file
91
tests/test_debug_autocomplete.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import pytest
|
||||
from bs4 import BeautifulSoup as Soup
|
||||
|
||||
from datasette.app import Datasette
|
||||
from datasette.database import Database
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_autocomplete_for_table():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_debug_autocomplete_for_table"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table authors (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
insert into authors (id, name) values
|
||||
(1, 'Ada Lovelace'),
|
||||
(2, 'Grace Hopper');
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/-/debug/autocomplete?database=data&table=authors")
|
||||
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
assert soup.select_one("h1").text == "Debug autocomplete"
|
||||
assert any(
|
||||
"autocomplete.js" in (script.get("src") or "")
|
||||
for script in soup.find_all("script")
|
||||
)
|
||||
autocomplete = soup.select_one("datasette-autocomplete")
|
||||
assert autocomplete is not None
|
||||
assert autocomplete["src"] == "/data/authors/-/autocomplete"
|
||||
assert soup.select_one("input#debug-autocomplete-input") is not None
|
||||
assert "Label column:" in response.text
|
||||
assert "<code>name</code>" in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_autocomplete_suggests_label_column_tables():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_debug_autocomplete_suggests"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table authors (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
create table releases (
|
||||
id integer primary key,
|
||||
title text
|
||||
);
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/-/debug/autocomplete")
|
||||
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
links = {a.text: a["href"] for a in soup.select("table.rows-and-columns a")}
|
||||
assert links == {
|
||||
"authors": "/-/debug/autocomplete?database=data&table=authors",
|
||||
"releases": "/-/debug/autocomplete?database=data&table=releases",
|
||||
}
|
||||
assert [code.text for code in soup.select("table.rows-and-columns code")] == [
|
||||
"name",
|
||||
"title",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_autocomplete_scan_limit():
|
||||
ds = Datasette(memory=True)
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_debug_autocomplete_scan_limit"), name="data"
|
||||
)
|
||||
await db.execute_write_script(
|
||||
"\n".join(
|
||||
f"create table t{i:03d} (id integer primary key);" for i in range(100)
|
||||
)
|
||||
+ "\ncreate table z_has_label (id integer primary key, name text);"
|
||||
)
|
||||
|
||||
response = await ds.client.get("/-/debug/autocomplete")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "No tables with detected label columns found." in response.text
|
||||
assert "Scanned 100 tables; stopped at the 100 table scan limit." in response.text
|
||||
assert "z_has_label" not in response.text
|
||||
|
|
@ -191,6 +191,7 @@ async def test_debug_menu_items_are_in_jump_for_debug_menu_permission():
|
|||
"Debug permissions": "/-/permissions",
|
||||
"Debug messages": "/-/messages",
|
||||
"Debug allow rules": "/-/allow-debug",
|
||||
"Debug autocomplete": "/-/debug/autocomplete",
|
||||
"Debug threads": "/-/threads",
|
||||
"Debug actor": "/-/actor",
|
||||
"Pattern portfolio": "/-/patterns",
|
||||
|
|
|
|||
|
|
@ -1062,6 +1062,7 @@ async def test_hook_menu_links(ds_client):
|
|||
async def test_hook_table_actions(ds_client):
|
||||
response = await ds_client.get("/fixtures/facetable")
|
||||
assert get_actions_links(response.text) == []
|
||||
assert get_actions_buttons(response.text) == []
|
||||
response_2 = await ds_client.get("/fixtures/facetable?_bot=1&_hello=BOB")
|
||||
assert ">Table actions<" in response_2.text
|
||||
assert sorted(
|
||||
|
|
@ -1071,6 +1072,23 @@ async def test_hook_table_actions(ds_client):
|
|||
{"label": "From async BOB", "href": "/", "description": None},
|
||||
{"label": "Table: facetable", "href": "/", "description": None},
|
||||
]
|
||||
response_3 = await ds_client.get("/fixtures/facetable?_bot=1&_button=1")
|
||||
assert get_actions_buttons(response_3.text) == [
|
||||
{
|
||||
"label": "Plugin button",
|
||||
"description": "Runs JavaScript from a plugin",
|
||||
"attrs": {
|
||||
"aria-label": "Plugin button for facetable",
|
||||
"class": ["button-as-link", "action-menu-button"],
|
||||
"data-database": "fixtures",
|
||||
"data-plugin-action": "plugin-button",
|
||||
"data-table": "facetable",
|
||||
"role": "menuitem",
|
||||
"tabindex": "-1",
|
||||
"type": "button",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1098,15 +1116,38 @@ def get_actions_links(html):
|
|||
links = []
|
||||
for a_el in details.select("a"):
|
||||
description = None
|
||||
if a_el.find("p") is not None:
|
||||
description = a_el.find("p").text.strip()
|
||||
a_el.find("p").extract()
|
||||
description_el = a_el.find(class_="dropdown-description")
|
||||
if description_el is not None:
|
||||
description = description_el.text.strip()
|
||||
description_el.extract()
|
||||
label = a_el.text.strip()
|
||||
href = a_el["href"]
|
||||
links.append({"label": label, "href": href, "description": description})
|
||||
return links
|
||||
|
||||
|
||||
def get_actions_buttons(html):
|
||||
soup = Soup(html, "html.parser")
|
||||
details = soup.find("details", {"class": "actions-menu-links"})
|
||||
if details is None:
|
||||
return []
|
||||
buttons = []
|
||||
for button_el in details.select("button.action-menu-button"):
|
||||
description = None
|
||||
description_el = button_el.find(class_="dropdown-description")
|
||||
if description_el is not None:
|
||||
description = description_el.text.strip()
|
||||
description_el.extract()
|
||||
buttons.append(
|
||||
{
|
||||
"label": button_el.text.strip(),
|
||||
"description": description,
|
||||
"attrs": dict(button_el.attrs),
|
||||
}
|
||||
)
|
||||
return buttons
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_url",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from datasette.app import Datasette
|
||||
from datasette.database import Database
|
||||
from bs4 import BeautifulSoup as Soup
|
||||
from .fixtures import make_app_client
|
||||
import pathlib
|
||||
|
|
@ -7,6 +8,21 @@ import urllib.parse
|
|||
from .utils import inner_html
|
||||
|
||||
|
||||
def table_data_from_soup(soup):
|
||||
import json
|
||||
import re
|
||||
|
||||
table_script = [
|
||||
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
|
||||
][0]
|
||||
match = re.search(
|
||||
r"window\._datasetteTableData\s*=\s*({.*?});",
|
||||
table_script.string,
|
||||
re.DOTALL,
|
||||
)
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"path,expected_definition_sql",
|
||||
|
|
@ -663,6 +679,13 @@ async def test_table_html_compound_primary_key(ds_client):
|
|||
assert [
|
||||
[str(td) for td in tr.select("td")] for tr in table.select("tbody tr")
|
||||
] == expected
|
||||
rows = table.select("tbody tr")
|
||||
assert rows[0]["data-row"] == "a,b"
|
||||
assert "data-row-pk-path" not in rows[0].attrs
|
||||
assert "data-row-label" not in rows[0].attrs
|
||||
assert rows[1]["data-row"] == "a~2Fb,~2Ec-d"
|
||||
assert "data-row-pk-path" not in rows[1].attrs
|
||||
assert "data-row-label" not in rows[1].attrs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -828,6 +851,524 @@ async def test_mobile_column_actions_present(ds_client, path):
|
|||
assert len(ths) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_delete_action_data_attributes():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"update-row": {"id": "root"},
|
||||
"delete-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_delete_actions"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text, score integer);
|
||||
insert into items (id, name, score) values (1, 'One', 5);
|
||||
""")
|
||||
response = await ds.client.get("/data/items", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
assert table_data_from_soup(soup) == {
|
||||
"database": "data",
|
||||
"table": "items",
|
||||
"tableUrl": "/data/items",
|
||||
}
|
||||
assert soup.select_one('button[data-table-action="insert-row"]') is None
|
||||
|
||||
row = soup.select_one("table.rows-and-columns tbody tr")
|
||||
assert row["data-row"] == "1"
|
||||
assert row["data-row-label"] == "One"
|
||||
assert {key for key in row.attrs if key.startswith("data-row")} == {
|
||||
"data-row",
|
||||
"data-row-label",
|
||||
}
|
||||
|
||||
edit_button = row.select_one(
|
||||
'button.row-inline-action-edit[data-row-action="edit"]'
|
||||
)
|
||||
assert edit_button is not None
|
||||
assert edit_button["aria-label"] == "Edit row 1 One"
|
||||
assert edit_button["title"] == "Edit row"
|
||||
assert edit_button.find("svg") is not None
|
||||
|
||||
button = row.select_one(
|
||||
'button.row-inline-action-delete[data-row-action="delete"]'
|
||||
)
|
||||
assert button is not None
|
||||
assert button["aria-label"] == "Delete row 1 One"
|
||||
assert button["title"] == "Delete row"
|
||||
assert button.find("svg") is not None
|
||||
|
||||
response = await ds.client.get("/data/items?_col=score", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
row = soup.select_one("table.rows-and-columns tbody tr")
|
||||
assert row["data-row"] == "1"
|
||||
assert "data-row-label" not in row.attrs
|
||||
|
||||
edit_button = row.select_one(
|
||||
'button.row-inline-action-edit[data-row-action="edit"]'
|
||||
)
|
||||
assert edit_button is not None
|
||||
assert edit_button["aria-label"] == "Edit row 1"
|
||||
|
||||
button = row.select_one(
|
||||
'button.row-inline-action-delete[data-row-action="delete"]'
|
||||
)
|
||||
assert button is not None
|
||||
assert button["aria-label"] == "Delete row 1"
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_insert_action_button_and_data():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"insert-row": {"id": "root"},
|
||||
},
|
||||
"column_types": {"body": "textarea"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_insert_action"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (
|
||||
id integer primary key,
|
||||
name text not null,
|
||||
score integer default 5,
|
||||
price numeric,
|
||||
created text default (datetime('now')),
|
||||
body text,
|
||||
typeless
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/items", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
|
||||
button = soup.select_one(
|
||||
'button.table-insert-row[data-table-action="insert-row"]'
|
||||
)
|
||||
assert button is not None
|
||||
assert button.text.strip() == "Insert row"
|
||||
assert button.find("svg") is not None
|
||||
assert button.find_parent("div", class_="table-row-toolbar") is not None
|
||||
|
||||
insert_data = table_data_from_soup(soup)["insertRow"]
|
||||
assert insert_data["path"] == "/data/items/-/insert"
|
||||
assert insert_data["tableName"] == "items"
|
||||
assert insert_data["primaryKeys"] == ["id"]
|
||||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"name",
|
||||
"score",
|
||||
"price",
|
||||
"created",
|
||||
"body",
|
||||
"typeless",
|
||||
]
|
||||
name, score, price, created, body, typeless = insert_data["columns"]
|
||||
assert name["notnull"] == 1
|
||||
assert name["sqlite_type"] == "TEXT"
|
||||
assert name["value_kind"] == "string"
|
||||
assert not name["has_default"]
|
||||
assert score["default"] == "5"
|
||||
assert score["has_default"]
|
||||
assert score["sqlite_type"] == "INTEGER"
|
||||
assert score["value_kind"] == "number"
|
||||
assert price["sqlite_type"] == "NUMERIC"
|
||||
assert price["value_kind"] == "string"
|
||||
assert created["default"] == "datetime('now')"
|
||||
assert created["has_default"]
|
||||
assert created["sqlite_type"] == "TEXT"
|
||||
assert body["sqlite_type"] == "TEXT"
|
||||
assert body["value_kind"] == "string"
|
||||
assert body["column_type"] == {"type": "textarea", "config": None}
|
||||
assert typeless["sqlite_type"] == "BLOB"
|
||||
assert typeless["value_kind"] == "string"
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_insert_action_includes_compound_primary_keys():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"memberships": {
|
||||
"permissions": {
|
||||
"insert-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_insert_compound_pk"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table memberships (
|
||||
account text,
|
||||
username text,
|
||||
role text,
|
||||
primary key (account, username)
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/memberships", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
insert_data = table_data_from_soup(Soup(response.text, "html.parser"))[
|
||||
"insertRow"
|
||||
]
|
||||
assert insert_data["tableName"] == "memberships"
|
||||
assert insert_data["primaryKeys"] == ["account", "username"]
|
||||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"account",
|
||||
"username",
|
||||
"role",
|
||||
]
|
||||
assert [column["is_pk"] for column in insert_data["columns"]] == [
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_data_includes_foreign_key_autocomplete_urls():
|
||||
ds = Datasette([])
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_foreign_key_autocomplete"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table authors (
|
||||
id integer primary key,
|
||||
name text
|
||||
);
|
||||
create table tags (
|
||||
slug text unique,
|
||||
name text
|
||||
);
|
||||
create table articles (
|
||||
id integer primary key,
|
||||
author_id integer references authors(id),
|
||||
implicit_author_id integer references authors,
|
||||
tag_slug text references tags(slug),
|
||||
title text
|
||||
);
|
||||
insert into authors (id, name) values (1, 'Ada Lovelace');
|
||||
insert into tags (slug, name) values ('science', 'Science');
|
||||
insert into articles (
|
||||
id,
|
||||
author_id,
|
||||
implicit_author_id,
|
||||
tag_slug,
|
||||
title
|
||||
) values (
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
'science',
|
||||
'Notes'
|
||||
);
|
||||
""")
|
||||
response = await ds.client.get("/data/articles")
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
table_data = table_data_from_soup(soup)
|
||||
assert table_data["foreignKeys"] == {
|
||||
"author_id": "/data/authors/-/autocomplete",
|
||||
"implicit_author_id": "/data/authors/-/autocomplete",
|
||||
}
|
||||
assert any(
|
||||
"autocomplete.js" in (script.get("src") or "")
|
||||
for script in soup.find_all("script")
|
||||
)
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_endpoint(ds_client):
|
||||
response = await ds_client.get("/fixtures/simple_primary_key/-/fragment?_row=1")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
soup = Soup(response.text, "html.parser")
|
||||
assert soup.find("html") is None
|
||||
rows = soup.select("[data-row]")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["data-row"] == "1"
|
||||
assert rows[0]["data-row-label"] == "hello"
|
||||
assert {key for key in rows[0].attrs if key.startswith("data-row")} == {
|
||||
"data-row",
|
||||
"data-row-label",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_row_parameter_replaces_pk_filters(ds_client):
|
||||
response = await ds_client.get(
|
||||
"/fixtures/simple_primary_key/-/fragment?id=2&_row=1"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
rows = soup.select("[data-row]")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["data-row"] == "1"
|
||||
assert rows[0]["data-row-label"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_page_edit_delete_action_menu_buttons():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"update-row": {"id": "root"},
|
||||
"delete-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_page_edit_delete_actions"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text, score integer);
|
||||
insert into items (id, name, score) values (1, 'One', 5);
|
||||
""")
|
||||
response = await ds.client.get("/data/items/1", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
soup = Soup(response.text, "html.parser")
|
||||
assert table_data_from_soup(soup) == {
|
||||
"database": "data",
|
||||
"table": "items",
|
||||
"tableUrl": "/data/items",
|
||||
}
|
||||
script_srcs = [script.get("src") or "" for script in soup.find_all("script")]
|
||||
assert any("edit-tools.js" in src for src in script_srcs)
|
||||
assert not any("table.js" in src for src in script_srcs)
|
||||
|
||||
row = soup.select_one("table.rows-and-columns tbody tr")
|
||||
assert row["data-row"] == "1"
|
||||
assert row["data-row-label"] == "One"
|
||||
|
||||
edit_button = soup.select_one(
|
||||
'details.actions-menu-links button.action-menu-button[data-row-action="edit"]'
|
||||
)
|
||||
assert edit_button is not None
|
||||
assert edit_button["aria-label"] == "Edit row 1 One"
|
||||
assert edit_button["data-row"] == "1"
|
||||
assert edit_button["data-row-label"] == "One"
|
||||
assert edit_button["role"] == "menuitem"
|
||||
assert edit_button.find("span", class_="dropdown-description").text.strip() == (
|
||||
"Open a dialog to edit this row."
|
||||
)
|
||||
edit_button.find("span").extract()
|
||||
assert edit_button.text.strip() == "Edit row"
|
||||
|
||||
delete_button = soup.select_one(
|
||||
'details.actions-menu-links button.action-menu-button[data-row-action="delete"]'
|
||||
)
|
||||
assert delete_button is not None
|
||||
assert delete_button["aria-label"] == "Delete row 1 One"
|
||||
assert delete_button["data-row"] == "1"
|
||||
assert delete_button["data-row-label"] == "One"
|
||||
assert delete_button["role"] == "menuitem"
|
||||
assert delete_button.find(
|
||||
"span", class_="dropdown-description"
|
||||
).text.strip() == ("Open a confirmation dialog to delete this row.")
|
||||
delete_button.find("span").extract()
|
||||
assert delete_button.text.strip() == "Delete row"
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_delete_redirect_to_table_sets_message():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"delete-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_delete_redirect"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text);
|
||||
insert into items (id, name) values (1, 'One');
|
||||
""")
|
||||
response = await ds.client.post(
|
||||
"/data/items/1/-/delete?_redirect_to_table=1", actor={"id": "root"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True, "redirect": "/data/items"}
|
||||
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
|
||||
["Deleted row 1 (One)", ds.INFO]
|
||||
]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_row_update_sets_message():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"update-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_row_update_message"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text);
|
||||
insert into items (id, name) values (1, 'One');
|
||||
""")
|
||||
long_name = "Two " + ("long label " * 12)
|
||||
truncated_name = long_name[:79] + "\u2026"
|
||||
response = await ds.client.post(
|
||||
"/data/items/1/-/update?_message=1",
|
||||
actor={"id": "root"},
|
||||
json={"update": {"name": long_name}, "return": True},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["row"]["name"] == long_name
|
||||
assert ds.unsign(response.cookies["ds_messages"], "messages") == [
|
||||
["Updated row 1 ({})".format(truncated_name), ds.INFO]
|
||||
]
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
def test_table_data_uses_base_url(app_client_base_url_prefix):
|
||||
response = app_client_base_url_prefix.get("/prefix/fixtures/simple_primary_key")
|
||||
assert response.status_code == 200
|
||||
import json
|
||||
import re
|
||||
|
||||
soup = Soup(response.text, "html.parser")
|
||||
table_script = [
|
||||
s for s in soup.find_all("script") if "_datasetteTableData" in (s.string or "")
|
||||
][0]
|
||||
match = re.search(
|
||||
r"window\._datasetteTableData\s*=\s*({.*?});",
|
||||
table_script.string,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert json.loads(match.group(1)) == {
|
||||
"database": "fixtures",
|
||||
"table": "simple_primary_key",
|
||||
"tableUrl": "/prefix/fixtures/simple_primary_key",
|
||||
}
|
||||
|
||||
|
||||
def test_table_fragment_custom_table_include():
|
||||
with make_app_client(
|
||||
template_dir=str(pathlib.Path(__file__).parent / "test_templates")
|
||||
) as client:
|
||||
response = client.get("/fixtures/complex_foreign_keys/-/fragment?f1=1&f2=2")
|
||||
assert response.status == 200
|
||||
assert (
|
||||
'<div class="custom-table-row">'
|
||||
'1 - 2 - <a href="/fixtures/simple_primary_key/1">hello</a> <em>1</em>'
|
||||
"</div>"
|
||||
) == str(Soup(response.text, "html.parser").select_one("div.custom-table-row"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_fragment_uses_render_cell_hook():
|
||||
from datasette import hookimpl
|
||||
from markupsafe import Markup
|
||||
|
||||
class TestRenderCellPlugin:
|
||||
__name__ = "TestRenderCellPlugin"
|
||||
|
||||
@hookimpl
|
||||
def render_cell(self, value, column, table, database):
|
||||
if database == "data" and table == "items" and column == "name":
|
||||
return Markup("<strong>{}</strong>".format(value))
|
||||
return None
|
||||
|
||||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
db = ds.add_memory_database("data")
|
||||
await db.execute_write("create table items (id integer primary key, name text)")
|
||||
await db.execute_write("insert into items values (1, 'Alice')")
|
||||
ds.pm.register(TestRenderCellPlugin(), name="TestRenderCellPlugin")
|
||||
try:
|
||||
response = await ds.client.get("/data/items/-/fragment?id=1")
|
||||
assert response.status_code == 200
|
||||
assert "<strong>Alice</strong>" in response.text
|
||||
finally:
|
||||
ds.pm.unregister(name="TestRenderCellPlugin")
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_row_table_renders_thead(ds_client):
|
||||
response = await ds_client.get("/fixtures/123_starts_with_digits")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue