mirror of
https://github.com/simonw/datasette.git
synced 2026-06-23 01:04:49 +02:00
Add alter table modal
- Register a built-in table action and expose alter-table metadata to table pages. - Build the client-side modal for editing columns, defaults, ordering, primary keys, and custom column types. - Add a review/apply confirmation flow with HTML and Playwright coverage. Refs #2788
This commit is contained in:
parent
b40665dd14
commit
fdd1b61a3e
8 changed files with 2414 additions and 3 deletions
29
datasette/default_table_actions.py
Normal file
29
datasette/default_table_actions.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from datasette import hookimpl
|
||||
from datasette.resources import TableResource
|
||||
|
||||
|
||||
@hookimpl
|
||||
def table_actions(datasette, actor, database, table, request):
|
||||
async def inner():
|
||||
db = datasette.get_database(database)
|
||||
if not db.is_mutable:
|
||||
return []
|
||||
if not await datasette.allowed(
|
||||
action="alter-table",
|
||||
resource=TableResource(database=database, table=table),
|
||||
actor=actor,
|
||||
):
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"type": "button",
|
||||
"label": "Alter table",
|
||||
"description": "Change columns and primary key for this table.",
|
||||
"attrs": {
|
||||
"aria-label": "Alter table {}".format(table),
|
||||
"data-table-action": "alter-table",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
return inner
|
||||
|
|
@ -31,6 +31,7 @@ DEFAULT_PLUGINS = (
|
|||
"datasette.default_debug_menu",
|
||||
"datasette.default_jump_items",
|
||||
"datasette.default_database_actions",
|
||||
"datasette.default_table_actions",
|
||||
"datasette.default_query_actions",
|
||||
"datasette.handle_exception",
|
||||
"datasette.forbidden",
|
||||
|
|
|
|||
|
|
@ -2032,6 +2032,505 @@ dialog.table-create-dialog::backdrop {
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
dialog.table-alter-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(980px, 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.table-alter-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
dialog.table-alter-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;
|
||||
}
|
||||
|
||||
.table-alter-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;
|
||||
}
|
||||
|
||||
.table-alter-dialog .modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.table-alter-form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-alter-error {
|
||||
border-left: 4px solid #b91c1c;
|
||||
border-radius: 4px;
|
||||
background: #fff1f1;
|
||||
color: #7f1d1d;
|
||||
font-size: 0.9rem;
|
||||
margin: 12px 24px 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.table-alter-error:focus {
|
||||
outline: 3px solid rgba(185, 28, 28, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.table-alter-fields {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 16px 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.table-alter-fields[hidden],
|
||||
.table-alter-dialog .modal-footer [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-alter-review {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
overflow-y: auto;
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.table-alter-review[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-alter-review-title {
|
||||
color: var(--ink);
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-alter-review-title:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.table-alter-review-intro {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-alter-review-warning {
|
||||
border-left: 4px solid #b91c1c;
|
||||
border-radius: 4px;
|
||||
background: #fff1f1;
|
||||
color: #7f1d1d;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.table-alter-review-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding-left: 1.4rem;
|
||||
}
|
||||
|
||||
.table-alter-review-list li {
|
||||
color: var(--ink);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.table-alter-review-damaging {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table-alter-review-name {
|
||||
background: #eef6ff;
|
||||
border: 1px solid #c9ddf2;
|
||||
border-radius: 4px;
|
||||
color: var(--ink);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-alter-columns {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-alter-column-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-alter-column-headings,
|
||||
.table-alter-column-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 1.2fr) minmax(7.5rem, 0.7fr) minmax(12rem, 1fr) max-content 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-alter-column-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-alter-column-headings {
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.table-alter-column-label {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.table-alter-input {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-alter-input-placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-alter-default-expr option,
|
||||
.table-alter-custom-column-type option {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.table-alter-default-expr option[value=""],
|
||||
.table-alter-custom-column-type option[value=""] {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-alter-input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
}
|
||||
|
||||
.table-alter-column-details {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 12px 16px;
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--rule);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.table-alter-column-details[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-alter-detail-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-alter-detail-label {
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.table-alter-detail-check {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-alter-not-null,
|
||||
.table-alter-primary-key {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.table-alter-detail-check input {
|
||||
flex: 0 0 auto;
|
||||
margin: 0.15rem 0 0;
|
||||
}
|
||||
|
||||
.table-alter-detail-check span {
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.table-alter-move-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 32px);
|
||||
gap: 4px;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.table-alter-more-options {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
justify-self: start;
|
||||
padding: 0;
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-alter-more-options:hover,
|
||||
.table-alter-more-options:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.table-alter-more-options:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.table-alter-more-options:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.table-alter-icon-button {
|
||||
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;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.table-alter-icon-button:hover,
|
||||
.table-alter-icon-button:focus {
|
||||
background: rgba(74, 85, 104, 0.07);
|
||||
}
|
||||
|
||||
.table-alter-icon-button:focus {
|
||||
outline: 3px solid #b3d4ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.table-alter-icon-button svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table-alter-add-column {
|
||||
appearance: none;
|
||||
justify-self: start;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.table-alter-add-column:hover,
|
||||
.table-alter-add-column:focus {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.table-alter-add-column:focus {
|
||||
outline: 3px solid rgba(26, 86, 219, 0.12);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.table-alter-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);
|
||||
}
|
||||
|
||||
.table-alter-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;
|
||||
}
|
||||
|
||||
.table-alter-dialog .btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.table-alter-dialog .btn-ghost:hover {
|
||||
background: var(--rule);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.table-alter-dialog .btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table-alter-dialog .btn-primary:hover {
|
||||
background: #1949b8;
|
||||
}
|
||||
|
||||
.table-alter-dialog .btn-primary:disabled,
|
||||
.table-alter-dialog .btn-primary:disabled:hover {
|
||||
background: #a0aec0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table-alter-dialog .btn:disabled,
|
||||
.table-alter-add-column:disabled,
|
||||
.table-alter-icon-button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
dialog.table-alter-dialog {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.table-alter-dialog .modal-header,
|
||||
.table-alter-fields,
|
||||
.table-alter-review {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
|
||||
.table-alter-error {
|
||||
margin-left: 18px;
|
||||
margin-right: 18px;
|
||||
}
|
||||
|
||||
.table-alter-column-headings {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-alter-column-row {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.table-alter-column-main {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(7.5rem, 0.8fr) 32px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.table-alter-column-name {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.table-alter-column-type {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.table-alter-remove-column {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.table-alter-custom-column-type {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.table-alter-move-controls {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.table-alter-more-options {
|
||||
align-self: center;
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.table-alter-column-details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.table-alter-dialog .modal-footer {
|
||||
padding-left: 18px;
|
||||
padding-right: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.row-link-with-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -212,6 +212,7 @@ class RowView(DataView):
|
|||
table,
|
||||
not is_table,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
"row_actions": row_actions,
|
||||
"top_row": make_slot_function(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ from datasette.filters import Filters
|
|||
import sqlite_utils
|
||||
from sqlite_utils.db import DEFAULT as SQLITE_UTILS_DEFAULT
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from .database import QueryView
|
||||
from .database import QueryView, _custom_column_type_options_for_create_table
|
||||
from .table_extras import (
|
||||
TABLE_EXTRA_BUNDLES,
|
||||
TableExtraContext,
|
||||
|
|
@ -62,6 +62,13 @@ LINK_WITH_LABEL = (
|
|||
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a> <em>{id}</em>'
|
||||
)
|
||||
LINK_WITH_VALUE = '<a href="{base_url}{database}/{table}/{link_id}">{id}</a>'
|
||||
ALTER_TABLE_COLUMN_TYPES = ["text", "integer", "float", "blob"]
|
||||
ALTER_TABLE_TYPE_FOR_SQLITE_TYPE = {
|
||||
SQLiteType.TEXT: "text",
|
||||
SQLiteType.INTEGER: "integer",
|
||||
SQLiteType.REAL: "float",
|
||||
SQLiteType.BLOB: "blob",
|
||||
}
|
||||
|
||||
|
||||
class Row:
|
||||
|
|
@ -283,7 +290,14 @@ async def _foreign_key_autocomplete_urls(
|
|||
|
||||
|
||||
async def _table_page_data(
|
||||
datasette, request, db, database_name, table_name, is_view, table_insert_ui
|
||||
datasette,
|
||||
request,
|
||||
db,
|
||||
database_name,
|
||||
table_name,
|
||||
is_view,
|
||||
table_insert_ui,
|
||||
table_alter_ui,
|
||||
):
|
||||
data = {
|
||||
"database": database_name,
|
||||
|
|
@ -292,6 +306,8 @@ async def _table_page_data(
|
|||
}
|
||||
if table_insert_ui:
|
||||
data["insertRow"] = table_insert_ui
|
||||
if table_alter_ui:
|
||||
data["alterTable"] = table_alter_ui
|
||||
if not is_view:
|
||||
foreign_keys = await _foreign_key_autocomplete_urls(
|
||||
datasette, request, db, database_name, table_name
|
||||
|
|
@ -354,6 +370,63 @@ async def _table_insert_ui(
|
|||
}
|
||||
|
||||
|
||||
async def _table_alter_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="alter-table",
|
||||
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 = []
|
||||
for column in await db.table_column_details(table_name):
|
||||
if column.hidden:
|
||||
continue
|
||||
sqlite_type = SQLiteType.from_declared_type(column.type)
|
||||
column_type = column_types_map.get(column.name)
|
||||
columns.append(
|
||||
{
|
||||
"name": column.name,
|
||||
"type": ALTER_TABLE_TYPE_FOR_SQLITE_TYPE.get(sqlite_type, "text"),
|
||||
"sqlite_type": sqlite_type.value,
|
||||
"notnull": column.notnull,
|
||||
"default": column.default_value,
|
||||
"has_default": column.default_value is not None,
|
||||
"is_pk": column.name in pks,
|
||||
"column_type": (
|
||||
{"type": column_type.name, "config": column_type.config}
|
||||
if column_type is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
data = {
|
||||
"path": "{}/-/alter".format(datasette.urls.table(database_name, table_name)),
|
||||
"tableName": table_name,
|
||||
"columns": columns,
|
||||
"primaryKeys": pks,
|
||||
"columnTypes": ALTER_TABLE_COLUMN_TYPES,
|
||||
"defaultExpressions": list(DEFAULT_EXPR_SQL),
|
||||
}
|
||||
can_set_column_type = await datasette.allowed(
|
||||
action="set-column-type",
|
||||
resource=TableResource(database=database_name, table=table_name),
|
||||
actor=request.actor,
|
||||
)
|
||||
if can_set_column_type:
|
||||
data["customColumnTypes"] = _custom_column_type_options_for_create_table(
|
||||
datasette
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
async def display_columns_and_rows(
|
||||
datasette,
|
||||
database_name,
|
||||
|
|
@ -2421,7 +2494,11 @@ async def table_view_data(
|
|||
table_insert_ui = await _table_insert_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
table_alter_ui = await _table_alter_ui(
|
||||
datasette, request, db, database_name, table_name, is_view, pks
|
||||
)
|
||||
data["table_insert_ui"] = table_insert_ui
|
||||
data["table_alter_ui"] = table_alter_ui
|
||||
data["table_page_data"] = await _table_page_data(
|
||||
datasette,
|
||||
request,
|
||||
|
|
@ -2430,6 +2507,7 @@ async def table_view_data(
|
|||
table_name,
|
||||
is_view,
|
||||
table_insert_ui,
|
||||
table_alter_ui,
|
||||
)
|
||||
|
||||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ def write_playwright_config(config_path):
|
|||
"notes": "textarea",
|
||||
},
|
||||
"permissions": {
|
||||
"alter-table": True,
|
||||
"insert-row": True,
|
||||
"update-row": True,
|
||||
"delete-row": True,
|
||||
|
|
@ -328,6 +329,215 @@ def test_create_table_flow(page, datasette_server):
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_flow(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
assert dialog.locator(".modal-title").inner_text() == "Alter table projects"
|
||||
assert dialog.locator(".table-alter-save").is_disabled()
|
||||
type_options = dialog.locator(".table-alter-column-type").first.locator("option")
|
||||
assert type_options.all_inner_texts() == [
|
||||
"text",
|
||||
"integer",
|
||||
"floating point number",
|
||||
"blob - binary data",
|
||||
]
|
||||
first_more_options = dialog.locator(".table-alter-more-options").first
|
||||
assert first_more_options.inner_text() == "> Advanced options"
|
||||
first_more_options.click()
|
||||
assert first_more_options.inner_text() == "v Hide options"
|
||||
expanded_options_text = dialog.locator(".table-alter-column-details").first.inner_text()
|
||||
assert dialog.locator(".table-alter-fields").evaluate(
|
||||
"node => node.scrollWidth <= node.clientWidth + 1"
|
||||
)
|
||||
assert "Not null" in expanded_options_text
|
||||
assert "This value cannot be left unset" in expanded_options_text
|
||||
assert "Default value" in expanded_options_text
|
||||
assert "or default to a specific time" in expanded_options_text
|
||||
assert "Primary key" in expanded_options_text
|
||||
assert "An ID that uniquely identifies this record" in expanded_options_text
|
||||
|
||||
dialog.locator(".table-alter-add-column").click()
|
||||
assert dialog.locator(".table-alter-save").is_enabled()
|
||||
dialog.locator(".table-alter-column-name").last.fill("status")
|
||||
dialog.locator(".table-alter-column-type").last.select_option("text")
|
||||
dialog.locator(".table-alter-default").last.fill("planned")
|
||||
dialog.locator(".table-alter-save").click()
|
||||
review = dialog.locator(".table-alter-review")
|
||||
review.wait_for()
|
||||
assert not dialog.locator(".table-alter-column-list").is_visible()
|
||||
review_text = review.inner_text()
|
||||
assert "Add column status as text, with default value planned." in review_text
|
||||
assert "Set column order to" not in review_text
|
||||
assert dialog.locator(".table-alter-back").is_visible()
|
||||
assert dialog.locator(".table-alter-save").inner_text() == "Apply changes"
|
||||
dialog.locator(".table-alter-save").click()
|
||||
|
||||
columns = []
|
||||
for _ in range(20):
|
||||
response = httpx.get(f"{datasette_server}data/projects.json?_extra=columns")
|
||||
response.raise_for_status()
|
||||
columns = response.json()["columns"]
|
||||
if "status" in columns:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert "status" in columns
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_primary_key_columns_stay_at_top(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
rows = dialog.locator(".table-alter-column-row")
|
||||
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
|
||||
first_row_move_buttons = rows.nth(0).locator(".table-alter-move-controls button")
|
||||
for i in range(first_row_move_buttons.count()):
|
||||
assert first_row_move_buttons.nth(i).is_disabled()
|
||||
assert (
|
||||
first_row_move_buttons.nth(i).get_attribute("title")
|
||||
== "Primary key columns are always listed first"
|
||||
)
|
||||
|
||||
assert rows.nth(1).locator(".table-alter-move-up").is_disabled()
|
||||
assert rows.nth(1).locator(".table-alter-move-top").get_attribute("title") == (
|
||||
"Primary key columns are always listed first"
|
||||
)
|
||||
assert rows.nth(1).locator(".table-alter-move-up").get_attribute("title") == (
|
||||
"Primary key columns are always listed first"
|
||||
)
|
||||
last_row = rows.nth(rows.count() - 1)
|
||||
assert last_row.locator(".table-alter-column-name").input_value() == "score"
|
||||
last_row.locator(".table-alter-move-top").click()
|
||||
assert rows.nth(0).locator(".table-alter-column-name").input_value() == "id"
|
||||
assert rows.nth(1).locator(".table-alter-column-name").input_value() == "score"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_review_rename_primary_key_column(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
save = dialog.locator(".table-alter-save")
|
||||
assert save.is_disabled()
|
||||
dialog.locator(".table-alter-column-name").first.fill("id3")
|
||||
assert save.is_enabled()
|
||||
save.click()
|
||||
|
||||
review = dialog.locator(".table-alter-review")
|
||||
review.wait_for()
|
||||
review_text = review.inner_text()
|
||||
assert "Rename column id to id3." in review_text
|
||||
assert "Set primary key to" not in review_text
|
||||
assert dialog.locator(".table-alter-review-name").all_inner_texts() == [
|
||||
"id",
|
||||
"id3",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_review_not_null_wording(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
dialog.locator(".table-alter-more-options").first.click()
|
||||
dialog.locator(".table-alter-not-null-input").first.check()
|
||||
dialog.locator(".table-alter-save").click()
|
||||
|
||||
review = dialog.locator(".table-alter-review")
|
||||
review.wait_for()
|
||||
assert "Change column id: not null (require values)." in review.inner_text()
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_review_warns_when_dropping_column(page, datasette_server):
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.locator("details.actions-menu-links summary").click()
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
remove_buttons = dialog.locator(".table-alter-remove-column")
|
||||
remove_buttons.nth(remove_buttons.count() - 1).click()
|
||||
dialog.locator(".table-alter-save").click()
|
||||
|
||||
review = dialog.locator(".table-alter-review")
|
||||
review.wait_for()
|
||||
assert not dialog.locator(".table-alter-column-list").is_visible()
|
||||
review_text = review.inner_text()
|
||||
assert "Warning: data in dropped columns will be permanently lost." in review_text
|
||||
assert "Drop column score." in review_text
|
||||
assert "Set column order to" not in review_text
|
||||
assert dialog.locator(".table-alter-review-damaging").inner_text() == (
|
||||
"Drop column score."
|
||||
)
|
||||
|
||||
dialog.locator(".table-alter-back").click()
|
||||
assert dialog.locator(".table-alter-column-list").is_visible()
|
||||
assert dialog.locator(".table-alter-save").inner_text() == "Review changes"
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_alter_table_cancel_skips_discard_prompt(page, datasette_server):
|
||||
def open_alter_dialog():
|
||||
page.locator("details.actions-menu-links").evaluate("node => node.open = true")
|
||||
page.locator('button[data-table-action="alter-table"]').click()
|
||||
dialog = page.locator("#table-alter-dialog")
|
||||
dialog.wait_for()
|
||||
return dialog
|
||||
|
||||
page.goto(f"{datasette_server}data/projects")
|
||||
page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
window.__discardConfirmMessages = [];
|
||||
window.confirm = (message) => {
|
||||
window.__discardConfirmMessages.push(message);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
dialog = open_alter_dialog()
|
||||
dialog.locator(".table-alter-add-column").click()
|
||||
dialog.locator(".table-alter-column-name").last.fill("cancel_me")
|
||||
dialog.locator(".table-alter-cancel").click()
|
||||
assert dialog.evaluate("node => node.open") is False
|
||||
assert page.evaluate("() => window.__discardConfirmMessages") == []
|
||||
|
||||
dialog = open_alter_dialog()
|
||||
dialog.locator(".table-alter-add-column").click()
|
||||
dialog.locator(".table-alter-column-name").last.fill("escape_me")
|
||||
page.keyboard.press("Escape")
|
||||
assert page.evaluate("() => window.__discardConfirmMessages") == [
|
||||
"Discard table changes?"
|
||||
]
|
||||
assert dialog.evaluate("node => node.open") is True
|
||||
|
||||
page.evaluate("() => window.__discardConfirmMessages = []")
|
||||
dialog.evaluate(
|
||||
"""node => node.dispatchEvent(new MouseEvent("click", {bubbles: true}))"""
|
||||
)
|
||||
assert page.evaluate("() => window.__discardConfirmMessages") == [
|
||||
"Discard table changes?"
|
||||
]
|
||||
assert dialog.evaluate("node => node.open") is True
|
||||
|
||||
|
||||
@pytest.mark.playwright
|
||||
def test_navigation_search_tracks_and_renders_recent_items(page, datasette_server):
|
||||
page.goto(datasette_server)
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,123 @@ async def test_database_create_table_data_includes_custom_column_types():
|
|||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_alter_action_button_and_data():
|
||||
ds = Datasette(
|
||||
[],
|
||||
config={
|
||||
"databases": {
|
||||
"data": {
|
||||
"tables": {
|
||||
"items": {
|
||||
"permissions": {
|
||||
"alter-table": {"id": "root"},
|
||||
"set-column-type": {"id": "root"},
|
||||
},
|
||||
"column_types": {"name": "textarea"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
try:
|
||||
db = ds.add_database(
|
||||
Database(ds, memory_name="test_table_alter_action"), name="data"
|
||||
)
|
||||
await db.execute_write_script("""
|
||||
create table items (
|
||||
id integer primary key,
|
||||
name text not null,
|
||||
score integer default 5
|
||||
);
|
||||
""")
|
||||
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.action-menu-button[data-table-action="alter-table"]'
|
||||
)
|
||||
assert button is not None
|
||||
assert button["aria-label"] == "Alter table items"
|
||||
assert button["role"] == "menuitem"
|
||||
description = button.find("span", class_="dropdown-description")
|
||||
assert description.text.strip() == (
|
||||
"Change columns and primary key for this table."
|
||||
)
|
||||
description.extract()
|
||||
assert button.text.strip() == "Alter table"
|
||||
assert any(
|
||||
"edit-tools.js" in script.get("src", "")
|
||||
for script in soup.find_all("script")
|
||||
)
|
||||
|
||||
alter_data = table_data_from_soup(soup)["alterTable"]
|
||||
assert alter_data["path"] == "/data/items/-/alter"
|
||||
assert alter_data["tableName"] == "items"
|
||||
assert alter_data["primaryKeys"] == ["id"]
|
||||
assert alter_data["columnTypes"] == ["text", "integer", "float", "blob"]
|
||||
assert alter_data["defaultExpressions"] == [
|
||||
"current_timestamp",
|
||||
"current_date",
|
||||
"current_time",
|
||||
]
|
||||
assert [option["name"] for option in alter_data["customColumnTypes"]] == [
|
||||
"email",
|
||||
"json",
|
||||
"textarea",
|
||||
"url",
|
||||
]
|
||||
assert alter_data["columns"] == [
|
||||
{
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"sqlite_type": "INTEGER",
|
||||
"notnull": 0,
|
||||
"default": None,
|
||||
"has_default": False,
|
||||
"is_pk": True,
|
||||
"column_type": None,
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"sqlite_type": "TEXT",
|
||||
"notnull": 1,
|
||||
"default": None,
|
||||
"has_default": False,
|
||||
"is_pk": False,
|
||||
"column_type": {"type": "textarea", "config": None},
|
||||
},
|
||||
{
|
||||
"name": "score",
|
||||
"type": "integer",
|
||||
"sqlite_type": "INTEGER",
|
||||
"notnull": 0,
|
||||
"default": "5",
|
||||
"has_default": True,
|
||||
"is_pk": False,
|
||||
"column_type": None,
|
||||
},
|
||||
]
|
||||
|
||||
response_without_permission = await ds.client.get(
|
||||
"/data/items", actor={"id": "someone-else"}
|
||||
)
|
||||
assert response_without_permission.status_code == 200
|
||||
soup_without_permission = Soup(response_without_permission.text, "html.parser")
|
||||
assert (
|
||||
soup_without_permission.select_one(
|
||||
'button[data-table-action="alter-table"]'
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert "alterTable" not in table_data_from_soup(soup_without_permission)
|
||||
finally:
|
||||
ds.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_insert_action_button_and_data():
|
||||
ds = Datasette(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue