mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
?_col=/?_nocol= to show/hide columns on the table page
Closes #615 * Cog icon for hiding columns * Show all columns cog menu item * Do not allow hide column on primary keys * Allow both ?_col= and ?_nocol= * De-duplicate if ?_col= passed multiple times * 400 error if user tries to ?_nocol= a primary key * Documentation for ?_col= and ?_nocol=
This commit is contained in:
parent
c0a748e5c3
commit
f1c29fd6a1
4 changed files with 142 additions and 15 deletions
|
|
@ -4,6 +4,8 @@ var DROPDOWN_HTML = `<div class="dropdown-menu">
|
||||||
<li><a class="dropdown-sort-asc" href="#">Sort ascending</a></li>
|
<li><a class="dropdown-sort-asc" href="#">Sort ascending</a></li>
|
||||||
<li><a class="dropdown-sort-desc" href="#">Sort descending</a></li>
|
<li><a class="dropdown-sort-desc" href="#">Sort descending</a></li>
|
||||||
<li><a class="dropdown-facet" href="#">Facet by this</a></li>
|
<li><a class="dropdown-facet" href="#">Facet by this</a></li>
|
||||||
|
<li><a class="dropdown-hide-column" href="#">Hide this column</a></li>
|
||||||
|
<li><a class="dropdown-show-all-columns" href="#">Show all columns</a></li>
|
||||||
<li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li>
|
<li><a class="dropdown-not-blank" href="#">Show not-blank rows</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="dropdown-column-type"></p>
|
<p class="dropdown-column-type"></p>
|
||||||
|
|
@ -24,7 +26,7 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
}
|
}
|
||||||
function paramsToUrl(params) {
|
function paramsToUrl(params) {
|
||||||
var s = params.toString();
|
var s = params.toString();
|
||||||
return s ? "?" + s : "";
|
return s ? "?" + s : location.pathname;
|
||||||
}
|
}
|
||||||
function sortDescUrl(column) {
|
function sortDescUrl(column) {
|
||||||
var params = getParams();
|
var params = getParams();
|
||||||
|
|
@ -45,6 +47,16 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
params.append("_facet", column);
|
params.append("_facet", column);
|
||||||
return paramsToUrl(params);
|
return paramsToUrl(params);
|
||||||
}
|
}
|
||||||
|
function hideColumnUrl(column) {
|
||||||
|
var params = getParams();
|
||||||
|
params.append("_nocol", column);
|
||||||
|
return paramsToUrl(params);
|
||||||
|
}
|
||||||
|
function showAllColumnsUrl() {
|
||||||
|
var params = getParams();
|
||||||
|
params.delete("_nocol");
|
||||||
|
return paramsToUrl(params);
|
||||||
|
}
|
||||||
function notBlankUrl(column) {
|
function notBlankUrl(column) {
|
||||||
var params = getParams();
|
var params = getParams();
|
||||||
params.set(`${column}__notblank`, "1");
|
params.set(`${column}__notblank`, "1");
|
||||||
|
|
@ -87,18 +99,33 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
var sortDesc = menu.querySelector("a.dropdown-sort-desc");
|
var sortDesc = menu.querySelector("a.dropdown-sort-desc");
|
||||||
var facetItem = menu.querySelector("a.dropdown-facet");
|
var facetItem = menu.querySelector("a.dropdown-facet");
|
||||||
var notBlank = menu.querySelector("a.dropdown-not-blank");
|
var notBlank = menu.querySelector("a.dropdown-not-blank");
|
||||||
|
var hideColumn = menu.querySelector("a.dropdown-hide-column");
|
||||||
|
var showAllColumns = menu.querySelector("a.dropdown-show-all-columns");
|
||||||
if (params.get("_sort") == column) {
|
if (params.get("_sort") == column) {
|
||||||
sort.style.display = "none";
|
sort.parentNode.style.display = "none";
|
||||||
} else {
|
} else {
|
||||||
sort.style.display = "block";
|
sort.parentNode.style.display = "block";
|
||||||
sort.setAttribute("href", sortAscUrl(column));
|
sort.setAttribute("href", sortAscUrl(column));
|
||||||
}
|
}
|
||||||
if (params.get("_sort_desc") == column) {
|
if (params.get("_sort_desc") == column) {
|
||||||
sortDesc.style.display = "none";
|
sortDesc.parentNode.style.display = "none";
|
||||||
} else {
|
} else {
|
||||||
sortDesc.style.display = "block";
|
sortDesc.parentNode.style.display = "block";
|
||||||
sortDesc.setAttribute("href", sortDescUrl(column));
|
sortDesc.setAttribute("href", sortDescUrl(column));
|
||||||
}
|
}
|
||||||
|
/* Show hide columns options */
|
||||||
|
if (params.get("_nocol")) {
|
||||||
|
showAllColumns.parentNode.style.display = "block";
|
||||||
|
showAllColumns.setAttribute("href", showAllColumnsUrl());
|
||||||
|
} else {
|
||||||
|
showAllColumns.parentNode.style.display = "none";
|
||||||
|
}
|
||||||
|
if (th.getAttribute("data-is-pk") != "1") {
|
||||||
|
hideColumn.parentNode.style.display = "block";
|
||||||
|
hideColumn.setAttribute("href", hideColumnUrl(column));
|
||||||
|
} else {
|
||||||
|
hideColumn.parentNode.style.display = "none";
|
||||||
|
}
|
||||||
/* Only show facet if it's not the first column, not selected, not a single PK */
|
/* Only show facet if it's not the first column, not selected, not a single PK */
|
||||||
var isFirstColumn =
|
var isFirstColumn =
|
||||||
th.parentElement.querySelector("th:first-of-type") == th;
|
th.parentElement.querySelector("th:first-of-type") == th;
|
||||||
|
|
@ -110,9 +137,9 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
params.getAll("_facet").includes(column) ||
|
params.getAll("_facet").includes(column) ||
|
||||||
isSinglePk
|
isSinglePk
|
||||||
) {
|
) {
|
||||||
facetItem.style.display = "none";
|
facetItem.parentNode.style.display = "none";
|
||||||
} else {
|
} else {
|
||||||
facetItem.style.display = "block";
|
facetItem.parentNode.style.display = "block";
|
||||||
facetItem.setAttribute("href", facetUrl(column));
|
facetItem.setAttribute("href", facetUrl(column));
|
||||||
}
|
}
|
||||||
/* Show notBlank option if not selected AND at least one visible blank value */
|
/* Show notBlank option if not selected AND at least one visible blank value */
|
||||||
|
|
@ -123,10 +150,10 @@ var DROPDOWN_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" heig
|
||||||
params.get(`${column}__notblank`) != "1" &&
|
params.get(`${column}__notblank`) != "1" &&
|
||||||
tdsForThisColumn.filter((el) => el.innerText.trim() == "").length
|
tdsForThisColumn.filter((el) => el.innerText.trim() == "").length
|
||||||
) {
|
) {
|
||||||
notBlank.style.display = "block";
|
notBlank.parentNode.style.display = "block";
|
||||||
notBlank.setAttribute("href", notBlankUrl(column));
|
notBlank.setAttribute("href", notBlankUrl(column));
|
||||||
} else {
|
} else {
|
||||||
notBlank.style.display = "none";
|
notBlank.parentNode.style.display = "none";
|
||||||
}
|
}
|
||||||
var columnTypeP = menu.querySelector(".dropdown-column-type");
|
var columnTypeP = menu.querySelector(".dropdown-column-type");
|
||||||
var columnType = th.dataset.columnType;
|
var columnType = th.dataset.columnType;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,41 @@ class Row:
|
||||||
|
|
||||||
|
|
||||||
class RowTableShared(DataView):
|
class RowTableShared(DataView):
|
||||||
|
async def columns_to_select(self, db, table, request):
|
||||||
|
table_columns = await db.table_columns(table)
|
||||||
|
pks = await db.primary_keys(table)
|
||||||
|
columns = list(table_columns)
|
||||||
|
if "_col" in request.args:
|
||||||
|
columns = list(pks)
|
||||||
|
_cols = request.args.getlist("_col")
|
||||||
|
bad_columns = [column for column in _cols if column not in table_columns]
|
||||||
|
if bad_columns:
|
||||||
|
raise DatasetteError(
|
||||||
|
"_col={} - invalid columns".format(", ".join(bad_columns)),
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
# De-duplicate maintaining order:
|
||||||
|
columns.extend(dict.fromkeys(_cols))
|
||||||
|
if "_nocol" in request.args:
|
||||||
|
# Return all columns EXCEPT these
|
||||||
|
bad_columns = [
|
||||||
|
column
|
||||||
|
for column in request.args.getlist("_nocol")
|
||||||
|
if (column not in table_columns) or (column in pks)
|
||||||
|
]
|
||||||
|
if bad_columns:
|
||||||
|
raise DatasetteError(
|
||||||
|
"_nocol={} - invalid columns".format(", ".join(bad_columns)),
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
tmp_columns = [
|
||||||
|
column
|
||||||
|
for column in columns
|
||||||
|
if column not in request.args.getlist("_nocol")
|
||||||
|
]
|
||||||
|
columns = tmp_columns
|
||||||
|
return columns
|
||||||
|
|
||||||
async def sortable_columns_for_table(self, database, table, use_rowid):
|
async def sortable_columns_for_table(self, database, table, use_rowid):
|
||||||
db = self.ds.databases[database]
|
db = self.ds.databases[database]
|
||||||
table_metadata = self.ds.table_metadata(database, table)
|
table_metadata = self.ds.table_metadata(database, table)
|
||||||
|
|
@ -323,18 +358,16 @@ class TableView(RowTableShared):
|
||||||
)
|
)
|
||||||
|
|
||||||
pks = await db.primary_keys(table)
|
pks = await db.primary_keys(table)
|
||||||
table_column_details = await db.table_column_details(table)
|
table_columns = await self.columns_to_select(db, table, request)
|
||||||
table_columns = [column.name for column in table_column_details]
|
select_clause = ", ".join(escape_sqlite(t) for t in table_columns)
|
||||||
|
|
||||||
select_columns = ", ".join(escape_sqlite(t) for t in table_columns)
|
|
||||||
|
|
||||||
use_rowid = not pks and not is_view
|
use_rowid = not pks and not is_view
|
||||||
if use_rowid:
|
if use_rowid:
|
||||||
select = f"rowid, {select_columns}"
|
select = f"rowid, {select_clause}"
|
||||||
order_by = "rowid"
|
order_by = "rowid"
|
||||||
order_by_pks = "rowid"
|
order_by_pks = "rowid"
|
||||||
else:
|
else:
|
||||||
select = select_columns
|
select = select_clause
|
||||||
order_by_pks = ", ".join([escape_sqlite(pk) for pk in pks])
|
order_by_pks = ", ".join([escape_sqlite(pk) for pk in pks])
|
||||||
order_by = order_by_pks
|
order_by = order_by_pks
|
||||||
|
|
||||||
|
|
@ -717,6 +750,8 @@ class TableView(RowTableShared):
|
||||||
column = fk["column"]
|
column = fk["column"]
|
||||||
if column not in columns_to_expand:
|
if column not in columns_to_expand:
|
||||||
continue
|
continue
|
||||||
|
if column not in columns:
|
||||||
|
continue
|
||||||
expanded_columns.append(column)
|
expanded_columns.append(column)
|
||||||
# Gather the values
|
# Gather the values
|
||||||
column_index = columns.index(column)
|
column_index = columns.index(column)
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,12 @@ You can filter the data returned by the table based on column values using a que
|
||||||
Special table arguments
|
Special table arguments
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``?_col=COLUMN1&_col=COLUMN2``
|
||||||
|
List specific columns to display. These will be shown along with any primary keys.
|
||||||
|
|
||||||
|
``?_nocol=COLUMN1&_nocol=COLUMN2``
|
||||||
|
List specific columns to hide - any column not listed will be displayed. Primary keys cannot be hidden.
|
||||||
|
|
||||||
``?_labels=on/off``
|
``?_labels=on/off``
|
||||||
Expand foreign key references for every possible column. See below.
|
Expand foreign key references for every possible column. See below.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2009,3 +2009,62 @@ def test_http_options_request(app_client):
|
||||||
response = app_client.request("/fixtures", method="OPTIONS")
|
response = app_client.request("/fixtures", method="OPTIONS")
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert response.text == "ok"
|
assert response.text == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected_columns",
|
||||||
|
(
|
||||||
|
("/fixtures/facetable.json?_col=created", ["pk", "created"]),
|
||||||
|
(
|
||||||
|
"/fixtures/facetable.json?_nocol=created",
|
||||||
|
[
|
||||||
|
"pk",
|
||||||
|
"planet_int",
|
||||||
|
"on_earth",
|
||||||
|
"state",
|
||||||
|
"city_id",
|
||||||
|
"neighborhood",
|
||||||
|
"tags",
|
||||||
|
"complex_array",
|
||||||
|
"distinct_some_null",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/fixtures/facetable.json?_col=state&_col=created",
|
||||||
|
["pk", "state", "created"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/fixtures/facetable.json?_col=state&_col=state",
|
||||||
|
["pk", "state"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/fixtures/facetable.json?_col=state&_col=created&_nocol=created",
|
||||||
|
["pk", "state"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/fixtures/simple_view.json?_nocol=content",
|
||||||
|
["upper_content"],
|
||||||
|
),
|
||||||
|
("/fixtures/simple_view.json?_col=content", ["content"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_col_nocol(app_client, path, expected_columns):
|
||||||
|
response = app_client.get(path)
|
||||||
|
assert response.status == 200
|
||||||
|
columns = response.json["columns"]
|
||||||
|
assert columns == expected_columns
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,expected_error",
|
||||||
|
(
|
||||||
|
("/fixtures/facetable.json?_col=bad", "_col=bad - invalid columns"),
|
||||||
|
("/fixtures/facetable.json?_nocol=bad", "_nocol=bad - invalid columns"),
|
||||||
|
("/fixtures/facetable.json?_nocol=pk", "_nocol=pk - invalid columns"),
|
||||||
|
("/fixtures/simple_view.json?_col=bad", "_col=bad - invalid columns"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_col_nocol_errors(app_client, path, expected_error):
|
||||||
|
response = app_client.get(path)
|
||||||
|
assert response.status == 400
|
||||||
|
assert response.json["error"] == expected_error
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue