mirror of
https://github.com/simonw/datasette.git
synced 2026-07-08 16:44:34 +02:00
Bulk row inserts / create table from data
Merge pull request #2813 from simonw/bulk-insert
This commit is contained in:
commit
c9975c1fe1
9 changed files with 3015 additions and 163 deletions
|
|
@ -1641,6 +1641,11 @@ dialog.row-edit-dialog::backdrop {
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-fields[hidden],
|
||||
.row-edit-bulk[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
|
|
@ -1798,6 +1803,207 @@ textarea.row-edit-input {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.row-edit-bulk-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-editor[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.row-edit-bulk-actions .btn {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-label {
|
||||
color: var(--ink);
|
||||
font-size: 0.82rem;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-control {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-help {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-copy-template-label-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-narrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-textarea {
|
||||
min-height: 16rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.row-edit-bulk-textarea.row-edit-bulk-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: #f8fbff;
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.row-edit-bulk-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-note label,
|
||||
.row-edit-bulk-note .button-as-link {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.row-edit-copy-template-label-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-copy-template-label-narrow {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-template-note-narrow {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-summary {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table-wrap {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th,
|
||||
.row-edit-bulk-preview-table td {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-table th:last-child,
|
||||
.row-edit-bulk-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-preview-null,
|
||||
.row-edit-bulk-preview-auto {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row-edit-bulk-progress-status {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
datasette-autocomplete {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
|
@ -1876,6 +2082,16 @@ datasette-autocomplete input[type="text"],
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.row-edit-mode-link {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.row-edit-mode-link[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row-edit-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -2072,6 +2288,120 @@ select.table-create-input {
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-create-columns[hidden],
|
||||
.table-create-data[hidden],
|
||||
.table-create-data-editor[hidden],
|
||||
.table-create-data-preview[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-create-data,
|
||||
.table-create-data-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-create-data-label {
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.table-create-data-textarea {
|
||||
min-height: 16rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.table-create-data-textarea.table-create-data-drop-target {
|
||||
border-color: var(--accent);
|
||||
background: #f8fbff;
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.table-create-data-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-create-data-note label,
|
||||
.table-create-data-note .button-as-link {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-create-data-preview {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-create-data-preview-summary {
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-create-data-pk-field {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table-wrap {
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
max-height: 18rem;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th,
|
||||
.table-create-data-preview-table td {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
border-right: 1px solid var(--rule);
|
||||
max-width: 18rem;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-weight: 600;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-create-data-preview-table th:last-child,
|
||||
.table-create-data-preview-table td:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.table-create-data-preview-null {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.table-create-column-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
|
@ -2299,6 +2629,16 @@ select.table-create-input {
|
|||
background: var(--paper);
|
||||
}
|
||||
|
||||
.table-create-mode-link {
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.table-create-mode-link[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-create-dialog .btn {
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
|
|
@ -3012,7 +3352,8 @@ select.table-alter-input {
|
|||
.row-edit-dialog .modal-header,
|
||||
.row-edit-summary,
|
||||
.row-edit-loading,
|
||||
.row-edit-fields {
|
||||
.row-edit-fields,
|
||||
.row-edit-bulk {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
|
@ -3031,6 +3372,15 @@ select.table-alter-input {
|
|||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row-edit-bulk-conflict-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.row-edit-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
|
|
@ -3062,6 +3412,11 @@ select.table-alter-input {
|
|||
padding-top: 0;
|
||||
}
|
||||
|
||||
.table-create-data-pk-field {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.table-create-column-headings {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -325,7 +325,7 @@ class DatabaseContext(Context):
|
|||
database_color: str = field(metadata={"help": "The color assigned to the database"})
|
||||
database_page_data: dict = field(
|
||||
metadata={
|
||||
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
|
||||
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.'
|
||||
}
|
||||
)
|
||||
database_actions: callable = field(
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ class TableContext(Context):
|
|||
)
|
||||
table_insert_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
}
|
||||
)
|
||||
table_alter_ui: dict = field(
|
||||
|
|
@ -480,8 +480,15 @@ async def _table_insert_ui(
|
|||
):
|
||||
return None
|
||||
|
||||
can_update = await datasette.allowed(
|
||||
action="update-row",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
|
||||
column_types_map = await datasette.get_column_types(database_name, table_name)
|
||||
columns = []
|
||||
bulk_columns = []
|
||||
column_details = await db.table_column_details(table_name)
|
||||
for column in column_details:
|
||||
if column.hidden:
|
||||
|
|
@ -492,32 +499,40 @@ async def _table_insert_ui(
|
|||
and len(pks) == 1
|
||||
and SQLiteType.from_declared_type(column.type) == SQLiteType.INTEGER
|
||||
)
|
||||
column_type = column_types_map.get(column.name)
|
||||
column_data = {
|
||||
"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,
|
||||
"is_auto_pk": is_auto_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
|
||||
),
|
||||
}
|
||||
bulk_columns.append(column_data)
|
||||
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
|
||||
),
|
||||
}
|
||||
)
|
||||
columns.append(column_data)
|
||||
|
||||
return {
|
||||
data = {
|
||||
"path": "{}/-/insert".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"bulkColumns": bulk_columns,
|
||||
"primaryKeys": pks,
|
||||
"maxInsertRows": datasette.setting("max_insert_rows"),
|
||||
}
|
||||
if can_update:
|
||||
data["upsertPath"] = "{}/-/upsert".format(
|
||||
datasette.urls.table(database_name, table_name)
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def _table_alter_ui(
|
||||
|
|
|
|||
|
|
@ -267,6 +267,11 @@ async def _create_table_ui_context(
|
|||
"databaseName": database_name,
|
||||
"columnTypes": CREATE_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": default_expression_options(),
|
||||
"canInsertRows": await datasette.allowed(
|
||||
action="insert-row",
|
||||
resource=DatabaseResource(database=database_name),
|
||||
actor=request.actor,
|
||||
),
|
||||
}
|
||||
can_set_column_type = await datasette.allowed(
|
||||
action="set-column-type",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@
|
|||
Changelog
|
||||
=========
|
||||
|
||||
.. _unreleased:
|
||||
|
||||
Unreleased
|
||||
----------
|
||||
|
||||
- Table pages now offer an "Insert multiple rows" mode in the row insertion dialog. This accepts pasted TSV, CSV or JSON, previews the parsed rows before inserting them, validates unknown columns as data is pasted and displays omitted auto integer primary keys as ``auto`` in the preview. (:pr:`2813`)
|
||||
- The bulk insert UI can skip rows with existing primary keys, or update existing rows and insert new rows using the existing ``/<database>/<table>/-/upsert`` API when the actor has both :ref:`insert-row <actions_insert_row>` and :ref:`update-row <actions_update_row>` permissions. (:pr:`2813`)
|
||||
- The "Create table" dialog now includes a "Create table from data" mode. Paste TSV, CSV or JSON rows to preview inferred columns and types, choose the table name and primary key, then create the table and insert those rows in one step. (:pr:`2813`)
|
||||
|
||||
.. _v1_0_a35:
|
||||
|
||||
1.0a35 (2026-06-23)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ The page listing the tables, views and queries in a database, e.g. /fixtures. Re
|
|||
The color assigned to the database
|
||||
|
||||
``database_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
|
||||
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions``, ``canInsertRows`` and optional ``customColumnTypes``.
|
||||
|
||||
``editable`` - ``bool``
|
||||
Boolean indicating if the database is editable
|
||||
|
|
@ -389,7 +389,7 @@ Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
|
|||
SQL definition for this table
|
||||
|
||||
``table_insert_ui`` - ``dict``
|
||||
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
|
||||
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``bulkColumns``, ``primaryKeys`` and ``maxInsertRows`` keys, plus optional ``upsertPath`` if the current actor has permission to update rows. ``columns`` lists columns for the single-row insert form, while ``bulkColumns`` lists columns for the bulk insert form. Each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``is_auto_pk``, ``value_kind`` and ``column_type`` keys.
|
||||
|
||||
``table_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ def find_free_port():
|
|||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def wait_for_server(process, url, timeout=10):
|
||||
def wait_for_server(process, url, timeout=30):
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = None
|
||||
while time.monotonic() < deadline:
|
||||
|
|
@ -36,7 +36,20 @@ def wait_for_server(process, url, timeout=10):
|
|||
except httpx.HTTPError as ex:
|
||||
last_error = repr(ex)
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"Timed out waiting for {url}: {last_error}")
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
stdout, stderr = process.communicate()
|
||||
else:
|
||||
stdout, stderr = process.communicate()
|
||||
raise AssertionError(
|
||||
f"Timed out waiting for {url}: {last_error}\n"
|
||||
f"stdout:\n{stdout}\n"
|
||||
f"stderr:\n{stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -102,6 +115,17 @@ def write_playwright_database(db_path):
|
|||
id integer primary key,
|
||||
created_ms integer default (CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER))
|
||||
);
|
||||
create table bulk_defaults (
|
||||
id integer primary key,
|
||||
title text not null,
|
||||
status text not null default 'todo',
|
||||
score integer default 5
|
||||
);
|
||||
create table upsert_items (
|
||||
id text primary key,
|
||||
title text,
|
||||
metadata text
|
||||
);
|
||||
insert into projects (title, metadata, logo, notes, score) values
|
||||
(
|
||||
'Build Datasette',
|
||||
|
|
@ -110,6 +134,8 @@ def write_playwright_database(db_path):
|
|||
'Initial notes',
|
||||
5
|
||||
);
|
||||
insert into upsert_items (id, title, metadata) values
|
||||
('existing', 'Existing title', '{"old": true}');
|
||||
""")
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -123,6 +149,7 @@ def write_playwright_config(config_path):
|
|||
"data": {
|
||||
"permissions": {
|
||||
"create-table": True,
|
||||
"insert-row": True,
|
||||
"set-column-type": True,
|
||||
},
|
||||
"tables": {
|
||||
|
|
@ -145,6 +172,17 @@ def write_playwright_config(config_path):
|
|||
"alter-table": True,
|
||||
},
|
||||
},
|
||||
"bulk_defaults": {
|
||||
"permissions": {
|
||||
"insert-row": True,
|
||||
},
|
||||
},
|
||||
"upsert_items": {
|
||||
"permissions": {
|
||||
"insert-row": True,
|
||||
"update-row": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -278,6 +316,41 @@ def project_row(datasette_server, pk):
|
|||
return rows[0]
|
||||
|
||||
|
||||
def bulk_default_rows(datasette_server, **filters):
|
||||
params = {
|
||||
"_shape": "objects",
|
||||
**{key: str(value) for key, value in filters.items()},
|
||||
}
|
||||
response = httpx.get(f"{datasette_server}data/bulk_defaults.json", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()["rows"]
|
||||
|
||||
|
||||
def upsert_item_rows(datasette_server, **filters):
|
||||
params = {
|
||||
"_shape": "objects",
|
||||
**{key: str(value) for key, value in filters.items()},
|
||||
}
|
||||
response = httpx.get(f"{datasette_server}data/upsert_items.json", params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()["rows"]
|
||||
|
||||
|
||||
def upsert_item_row(datasette_server, pk):
|
||||
rows = upsert_item_rows(datasette_server, id=pk)
|
||||
assert len(rows) == 1
|
||||
return rows[0]
|
||||
|
||||
|
||||
def open_bulk_insert_dialog(page, url):
|
||||
page.goto(url)
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
return dialog
|
||||
|
||||
|
||||
def open_jump_menu(page):
|
||||
page.keyboard.press("/")
|
||||
page.locator("navigation-search .search-input").wait_for()
|
||||
|
|
@ -381,6 +454,165 @@ def test_create_table_flow(page, datasette_server):
|
|||
assert "NOT NULL DEFAULT 'Untitled'" in schema
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_from_data_flow(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-database-action="create-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-create-dialog")
|
||||
dialog.wait_for()
|
||||
from_data = dialog.locator(".table-create-from-data")
|
||||
assert from_data.inner_text() == "Create table from data"
|
||||
from_data.click()
|
||||
|
||||
assert dialog.locator(".table-create-columns").is_hidden()
|
||||
assert dialog.locator(".table-create-data").is_visible()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
|
||||
assert (
|
||||
dialog.locator(".table-create-data-note").inner_text()
|
||||
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
|
||||
)
|
||||
assert dialog.locator(".table-create-data-open-file").inner_text() == "open a file"
|
||||
assert (
|
||||
dialog.locator(".table-create-data-editor").evaluate(
|
||||
"""node => Array.from(node.children)
|
||||
.filter((child) => !child.hidden)
|
||||
.map((child) => child.className)
|
||||
.join(" ")"""
|
||||
)
|
||||
== ("table-create-data-note table-create-input table-create-data-textarea")
|
||||
)
|
||||
assert dialog.locator(".table-create-manual").inner_text() == (
|
||||
"Create table manually"
|
||||
)
|
||||
assert (
|
||||
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
|
||||
== "table-create-data-textarea"
|
||||
)
|
||||
|
||||
textarea = dialog.locator(".table-create-data-textarea")
|
||||
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
|
||||
node.addEventListener("input", () => resolve(node.value), { once: true });
|
||||
const file = new File(["short_id,name\\nx,Ada"], "Repo Export 2026!!.CSV", { type: "text/csv" });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
})""")
|
||||
assert dropped_value == "short_id,name\nx,Ada"
|
||||
assert dialog.locator(".table-create-table-name").input_value() == (
|
||||
"repo_export_2026"
|
||||
)
|
||||
|
||||
dialog.locator(".table-create-table-name").fill("playwright_from_data")
|
||||
textarea.fill(
|
||||
json.dumps(
|
||||
{
|
||||
"metadata": [{"short_id": "a", "name": "Ignored", "score": 9}],
|
||||
"people": [
|
||||
{"short_id": "b", "name": "Ada", "score": 1},
|
||||
{"short_id": "c", "name": "Bob", "score": 2.5},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
dialog.locator(".table-create-save").click()
|
||||
|
||||
assert dialog.locator(".table-create-data-textarea").is_hidden()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Create table"
|
||||
assert dialog.locator(".table-create-cancel").inner_text() == "Back"
|
||||
assert dialog.locator(".table-create-data-preview-summary").inner_text() == (
|
||||
"Previewing 2 rows."
|
||||
)
|
||||
assert dialog.locator(".table-create-data-primary-key").input_value() == "short_id"
|
||||
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
|
||||
assert "short_id" in preview_text
|
||||
assert "Ada" in preview_text
|
||||
assert "2.5" in preview_text
|
||||
preview_cell_style = dialog.locator(
|
||||
".table-create-data-preview-table td"
|
||||
).first.evaluate(
|
||||
"""node => ({
|
||||
overflowWrap: getComputedStyle(node).overflowWrap,
|
||||
whiteSpace: getComputedStyle(node).whiteSpace
|
||||
})"""
|
||||
)
|
||||
assert preview_cell_style == {
|
||||
"overflowWrap": "anywhere",
|
||||
"whiteSpace": "normal",
|
||||
}
|
||||
|
||||
dialog.locator(".table-create-cancel").click()
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".table-create-data-textarea").is_visible()
|
||||
assert '"people"' in dialog.locator(".table-create-data-textarea").input_value()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Preview rows"
|
||||
|
||||
dialog.locator(".table-create-save").click()
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Create table"
|
||||
dialog.locator(".table-create-save").click()
|
||||
page.wait_for_url("**/data/playwright_from_data")
|
||||
|
||||
response = httpx.get(
|
||||
f"{datasette_server}data/playwright_from_data.json?_shape=objects"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
assert data["rows"] == [
|
||||
{"short_id": "b", "name": "Ada", "score": 1},
|
||||
{"short_id": "c", "name": "Bob", "score": 2.5},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_from_csv_keeps_numeric_type_when_values_are_blank(
|
||||
page, datasette_server
|
||||
):
|
||||
page.goto(f"{datasette_server}data")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-database-action="create-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-create-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".table-create-from-data").click()
|
||||
dialog.locator(".table-create-table-name").fill("playwright_numeric_blanks")
|
||||
dialog.locator(".table-create-data-textarea").fill("name,score\nA,1\nB,")
|
||||
dialog.locator(".table-create-save").click()
|
||||
|
||||
assert dialog.locator(".table-create-save").inner_text() == "Create table"
|
||||
preview_text = dialog.locator(".table-create-data-preview-table").inner_text()
|
||||
assert "A" in preview_text
|
||||
assert "1" in preview_text
|
||||
assert "B" in preview_text
|
||||
assert "null" in preview_text
|
||||
|
||||
dialog.locator(".table-create-save").click()
|
||||
page.wait_for_url("**/data/playwright_numeric_blanks")
|
||||
|
||||
response = httpx.get(
|
||||
f"{datasette_server}data/playwright_numeric_blanks.json?_shape=objects"
|
||||
)
|
||||
response.raise_for_status()
|
||||
assert response.json()["rows"] == [
|
||||
{"name": "A", "score": 1},
|
||||
{"name": "B", "score": None},
|
||||
]
|
||||
|
||||
schema_response = httpx.get(
|
||||
f"{datasette_server}data/-/query.json",
|
||||
params={
|
||||
"sql": (
|
||||
"select type from pragma_table_info('playwright_numeric_blanks') "
|
||||
"where name = 'score'"
|
||||
)
|
||||
},
|
||||
)
|
||||
schema_response.raise_for_status()
|
||||
assert schema_response.json()["rows"] == [{"type": "INTEGER"}]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_create_table_foreign_key_selection_updates_column_type(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data")
|
||||
|
|
@ -801,11 +1033,128 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins(
|
|||
|
||||
@pytest.mark.playwright
|
||||
def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
|
||||
page.add_init_script("""
|
||||
(() => {
|
||||
let clipboardText = "";
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
get: () => ({
|
||||
writeText: async (text) => {
|
||||
clipboardText = String(text);
|
||||
},
|
||||
readText: async () => clipboardText,
|
||||
}),
|
||||
});
|
||||
})();
|
||||
""")
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
bulk_insert = dialog.locator(".row-edit-bulk-insert")
|
||||
bulk_insert.wait_for()
|
||||
assert bulk_insert.inner_text() == "Insert multiple rows"
|
||||
current_url = page.url
|
||||
bulk_insert.click()
|
||||
assert page.url == current_url
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".row-edit-fields").is_hidden()
|
||||
assert dialog.locator(".row-edit-bulk").is_visible()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
|
||||
assert (
|
||||
dialog.locator(".row-edit-bulk-note").inner_text()
|
||||
== "Paste TSV, CSV, or JSON. You can also open a file or drop it onto this textarea"
|
||||
)
|
||||
assert dialog.locator(".row-edit-bulk-open-file").inner_text() == "open a file"
|
||||
assert (
|
||||
dialog.locator(".row-edit-bulk-editor").evaluate(
|
||||
"""node => Array.from(node.children)
|
||||
.filter((child) => !child.hidden)
|
||||
.map((child) => child.className)
|
||||
.join(" ")"""
|
||||
)
|
||||
== (
|
||||
"row-edit-bulk-note row-edit-input row-edit-bulk-textarea "
|
||||
"row-edit-bulk-actions"
|
||||
)
|
||||
)
|
||||
copy_template = dialog.locator(".row-edit-copy-template")
|
||||
assert copy_template.inner_text() == "Copy spreadsheet template"
|
||||
assert (
|
||||
dialog.get_by_label("Paste TSV, CSV, or JSON").get_attribute("id")
|
||||
== "row-edit-bulk-textarea"
|
||||
)
|
||||
assert dialog.locator(".row-edit-copy-template-label-narrow").text_content() == (
|
||||
"Copy template"
|
||||
)
|
||||
assert dialog.locator(".row-edit-bulk-template-note").inner_text() == (
|
||||
"You can paste the template into Google Sheets or Excel."
|
||||
)
|
||||
assert dialog.locator(".row-edit-bulk-template-note-narrow").text_content() == (
|
||||
"Paste into Google Sheets or Excel"
|
||||
)
|
||||
copy_template.click()
|
||||
page.wait_for_function(
|
||||
"""() => document.querySelector(".row-edit-copy-template").textContent === "Copied" """
|
||||
)
|
||||
assert page.evaluate("navigator.clipboard.readText()") == (
|
||||
"title\tmetadata\tlogo\tnotes\tscore"
|
||||
)
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
textarea.fill("title\tmetadata\nFrom TSV\t{}")
|
||||
assert textarea.input_value() == "title\tmetadata\nFrom TSV\t{}"
|
||||
dropped_value = textarea.evaluate("""node => new Promise((resolve) => {
|
||||
node.addEventListener("input", () => resolve(node.value), { once: true });
|
||||
const file = new File(["\\n\\ntitle,metadata\\nFrom CSV,{}\\n,\\n , \\n"], "rows.csv", { type: "text/csv" });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
node.dispatchEvent(new DragEvent("dragenter", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
node.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
|
||||
})""")
|
||||
assert dropped_value == "\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
|
||||
dialog.locator(".row-edit-save").click()
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".row-edit-bulk-textarea").is_hidden()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
|
||||
"Previewing 1 row."
|
||||
)
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "id" in preview_text
|
||||
assert "title" in preview_text
|
||||
assert "metadata" in preview_text
|
||||
assert "From CSV" in preview_text
|
||||
assert dialog.locator(".row-edit-bulk-preview-auto").first.text_content() == "auto"
|
||||
assert "null" not in preview_text
|
||||
assert "undefined" not in preview_text
|
||||
preview_cell_style = dialog.locator(
|
||||
".row-edit-bulk-preview-table td"
|
||||
).first.evaluate(
|
||||
"""node => ({
|
||||
overflowWrap: getComputedStyle(node).overflowWrap,
|
||||
whiteSpace: getComputedStyle(node).whiteSpace
|
||||
})"""
|
||||
)
|
||||
assert preview_cell_style == {
|
||||
"overflowWrap": "anywhere",
|
||||
"whiteSpace": "normal",
|
||||
}
|
||||
assert dialog.locator(".row-edit-cancel").inner_text() == "Back"
|
||||
dialog.locator(".row-edit-cancel").click()
|
||||
assert dialog.evaluate("node => node.open")
|
||||
assert dialog.locator(".row-edit-bulk-textarea").is_visible()
|
||||
assert dialog.locator(".row-edit-bulk-textarea").input_value() == (
|
||||
"\n\ntitle,metadata\nFrom CSV,{}\n,\n , \n"
|
||||
)
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Preview rows"
|
||||
single_insert = dialog.locator(".row-edit-single-insert")
|
||||
assert single_insert.inner_text() == "Insert single row"
|
||||
single_insert.click()
|
||||
assert dialog.locator(".row-edit-bulk").is_hidden()
|
||||
assert dialog.locator(".row-edit-fields").is_visible()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert row"
|
||||
dialog.locator('input[name="title"]').fill("Launch Datasette Cloud")
|
||||
dialog.locator('textarea[name="metadata"]').fill(
|
||||
'{"ok": false, "source": "playwright"}'
|
||||
|
|
@ -835,6 +1184,206 @@ def test_insert_row_flow_uses_custom_column_field(page, datasette_server):
|
|||
assert data["score"] == 5
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_preview_inserts_rows(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
dialog.locator(".row-edit-bulk-textarea").fill(
|
||||
json.dumps(
|
||||
{
|
||||
"metadata": [{"title": "Ignored", "metadata": "{}"}],
|
||||
"projects": [
|
||||
{"title": "Bulk one", "metadata": "{}"},
|
||||
{"title": "Bulk two", "metadata": "{}"},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
dialog.locator(".row-edit-save").click()
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
dialog.locator(".row-edit-save").click()
|
||||
dialog.locator(
|
||||
".row-edit-bulk-progress-status", has_text="2 rows inserted."
|
||||
).wait_for()
|
||||
assert dialog.locator(".row-edit-cancel").inner_text() == "Close and view table"
|
||||
dialog.locator(".row-edit-cancel").click()
|
||||
page.wait_for_load_state("domcontentloaded")
|
||||
assert page.url == f"{datasette_server}data/projects"
|
||||
|
||||
assert project_rows(datasette_server, title="Bulk one")
|
||||
assert project_rows(datasette_server, title="Bulk two")
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_upsert_option_updates_existing_and_inserts_new(
|
||||
page, datasette_server
|
||||
):
|
||||
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/upsert_items")
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
conflict_field = dialog.locator(".row-edit-bulk-conflict")
|
||||
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
|
||||
|
||||
assert conflict_field.is_hidden()
|
||||
textarea.fill("title\nNo primary key")
|
||||
assert conflict_field.is_hidden()
|
||||
|
||||
textarea.fill(
|
||||
"id,title,metadata\nexisting,Updated by upsert,{}\nnew,Inserted by upsert,{}"
|
||||
)
|
||||
assert conflict_field.is_visible()
|
||||
assert (
|
||||
dialog.locator(".row-edit-bulk-conflict-label").inner_text()
|
||||
== "If the row exists already"
|
||||
)
|
||||
assert conflict_select.input_value() == "ignore"
|
||||
assert (
|
||||
conflict_select.evaluate("""node => Array.from(node.options)
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => option.textContent.trim())""")
|
||||
== [
|
||||
"Stop with an error",
|
||||
"Skip existing rows",
|
||||
"Update existing and insert new",
|
||||
]
|
||||
)
|
||||
|
||||
conflict_select.select_option("upsert")
|
||||
assert conflict_select.input_value() == "upsert"
|
||||
dialog.locator(".row-edit-save").click()
|
||||
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Update or insert rows"
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "Updated by upsert" in preview_text
|
||||
assert "Inserted by upsert" in preview_text
|
||||
|
||||
dialog.locator(".row-edit-save").click()
|
||||
dialog.locator(
|
||||
".row-edit-bulk-progress-status", has_text="2 rows upserted."
|
||||
).wait_for()
|
||||
|
||||
assert upsert_item_row(datasette_server, "existing")["title"] == (
|
||||
"Updated by upsert"
|
||||
)
|
||||
assert upsert_item_row(datasette_server, "new")["title"] == "Inserted by upsert"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_conflicts_hide_upsert_without_update_permission(
|
||||
page, datasette_server
|
||||
):
|
||||
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/bulk_defaults")
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
conflict_field = dialog.locator(".row-edit-bulk-conflict")
|
||||
conflict_select = dialog.locator(".row-edit-bulk-conflict-mode")
|
||||
|
||||
textarea.fill("title\nOnly title")
|
||||
assert conflict_field.is_hidden()
|
||||
|
||||
textarea.fill("id,title\n1,Only title")
|
||||
assert conflict_field.is_visible()
|
||||
assert conflict_select.input_value() == "ignore"
|
||||
assert (
|
||||
conflict_select.evaluate("""node => Array.from(node.options)
|
||||
.filter((option) => !option.hidden)
|
||||
.map((option) => option.textContent.trim())""")
|
||||
== [
|
||||
"Stop with an error",
|
||||
"Skip existing rows",
|
||||
]
|
||||
)
|
||||
assert conflict_select.locator('option[value="upsert"]').evaluate(
|
||||
"node => node.hidden && node.disabled"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_live_validation_reports_unknown_columns(page, datasette_server):
|
||||
dialog = open_bulk_insert_dialog(page, f"{datasette_server}data/projects")
|
||||
textarea = dialog.locator(".row-edit-bulk-textarea")
|
||||
error = dialog.locator(".row-edit-error")
|
||||
save = dialog.locator(".row-edit-save")
|
||||
|
||||
textarea.fill(json.dumps([{"id2": 1, "title": "Unknown column"}]))
|
||||
error.wait_for()
|
||||
assert error.inner_text() == "JSON row 1 has unknown column id2."
|
||||
assert save.is_disabled()
|
||||
assert textarea.evaluate("node => document.activeElement === node")
|
||||
|
||||
textarea.fill("id2,title\n1,Unknown column")
|
||||
assert error.inner_text() == "Unknown column id2 in header row."
|
||||
assert save.is_disabled()
|
||||
|
||||
textarea.fill("[")
|
||||
assert error.is_hidden()
|
||||
assert not save.is_disabled()
|
||||
|
||||
textarea.fill(json.dumps([{"id": 1, "title": "Known column"}]))
|
||||
assert error.is_hidden()
|
||||
assert not save.is_disabled()
|
||||
assert dialog.locator(".row-edit-bulk-conflict").is_visible()
|
||||
assert dialog.locator(".row-edit-bulk-conflict-mode").input_value() == "ignore"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_omits_columns_absent_from_pasted_input(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/bulk_defaults")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
dialog.locator(".row-edit-bulk-textarea").fill("title\nOnly title")
|
||||
dialog.locator(".row-edit-save").click()
|
||||
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "Only title" in preview_text
|
||||
assert "undefined" not in preview_text
|
||||
assert dialog.locator(".row-edit-bulk-preview-auto").inner_text() == "auto"
|
||||
|
||||
dialog.locator(".row-edit-save").click()
|
||||
dialog.locator(
|
||||
".row-edit-bulk-progress-status", has_text="1 row inserted."
|
||||
).wait_for()
|
||||
|
||||
rows = bulk_default_rows(datasette_server, title="Only title")
|
||||
assert rows == [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Only title",
|
||||
"status": "todo",
|
||||
"score": 5,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_bulk_insert_preview_accepts_single_column_input(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator('button[data-table-action="insert-row"]').click()
|
||||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".row-edit-bulk-insert").click()
|
||||
dialog.locator(".row-edit-bulk-textarea").fill("title\none\ntwo\nthree")
|
||||
dialog.locator(".row-edit-save").click()
|
||||
|
||||
assert dialog.locator(".row-edit-save").inner_text() == "Insert these rows"
|
||||
assert dialog.locator(".row-edit-bulk-preview-summary").inner_text() == (
|
||||
"Previewing 3 rows."
|
||||
)
|
||||
preview_text = dialog.locator(".row-edit-bulk-preview-table").inner_text()
|
||||
assert "one" in preview_text
|
||||
assert "two" in preview_text
|
||||
assert "three" in preview_text
|
||||
assert "null" not in preview_text
|
||||
assert "undefined" not in preview_text
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
|
|
@ -842,6 +1391,9 @@ def test_edit_row_flow_validates_json_and_saves_changes(page, datasette_server):
|
|||
|
||||
dialog = page.locator("#row-edit-dialog")
|
||||
dialog.wait_for()
|
||||
assert dialog.locator(".row-edit-bulk-insert").is_hidden()
|
||||
assert dialog.locator(".row-edit-single-insert").is_hidden()
|
||||
assert dialog.locator(".row-edit-bulk").is_hidden()
|
||||
title = dialog.locator('input[name="title"]')
|
||||
title.wait_for()
|
||||
title.fill("Build Datasette, edited")
|
||||
|
|
|
|||
|
|
@ -1027,6 +1027,7 @@ async def test_database_create_table_action_button_and_data():
|
|||
"databaseName": "data",
|
||||
"columnTypes": ["text", "integer", "float", "blob"],
|
||||
"defaultExpressions": DEFAULT_EXPRESSION_OPTIONS,
|
||||
"canInsertRows": False,
|
||||
},
|
||||
}
|
||||
assert "customColumnTypes" not in database_data_from_soup(soup)["createTable"]
|
||||
|
|
@ -1050,6 +1051,40 @@ async def test_database_create_table_action_button_and_data():
|
|||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_create_table_data_includes_insert_row_permission():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"permissions": {
|
||||
"create-table": {"id": "root"},
|
||||
"insert-row": {"id": "root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_database_create_table_insert_permission"),
|
||||
name="data",
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (id integer primary key, name text);
|
||||
""")
|
||||
|
||||
response = await ds.client.get("/data", actor={"id": "root"})
|
||||
assert response.status_code == 200
|
||||
create_table_data = database_data_from_soup(Soup(response.text, "html.parser"))[
|
||||
"createTable"
|
||||
]
|
||||
assert create_table_data["canInsertRows"] is True
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_create_table_data_includes_custom_column_types():
|
||||
ds = Datasette(
|
||||
|
|
@ -1316,6 +1351,7 @@ async def test_table_insert_action_button_and_data():
|
|||
assert insert_data["path"] == "/data/items/-/insert"
|
||||
assert insert_data["tableName"] == "items"
|
||||
assert insert_data["primaryKeys"] == ["id"]
|
||||
assert insert_data["maxInsertRows"] == 100
|
||||
assert [column["name"] for column in insert_data["columns"]] == [
|
||||
"name",
|
||||
"score",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue