diff --git a/datasette/app.py b/datasette/app.py index da40df1e..5e2d21d1 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -524,15 +524,21 @@ class RowTableShared(BaseView): cells.append({ 'column': 'Link', 'value': jinja2.Markup( - '{flat_pks}'.format( + '{flat_pks}'.format( database=database, table=urllib.parse.quote_plus(table), - flat_pks=path_from_row_pks(row, pks, not pks), + flat_pks=str(jinja2.escape(path_from_row_pks(row, pks, not pks, False))), + flat_pks_quoted=path_from_row_pks(row, pks, not pks) ) ), }) + for value, column_dict in zip(row, columns): column = column_dict['name'] + if link_column and len(pks) == 1 and column == pks[0]: + # If there's a simple primary key, don't repeat the value as it's + # already shown in the link column. + continue if (column, value) in labeled_fks: other_table, label = labeled_fks[(column, value)] display_value = jinja2.Markup( @@ -559,17 +565,17 @@ class RowTableShared(BaseView): url=jinja2.escape(value.strip()) ) ) + elif column in table_metadata.get('units', {}) and value != '': + # Interpret units using pint + value = value * ureg(table_metadata['units'][column]) + # Pint uses floating point which sometimes introduces errors in the compact + # representation, which we have to round off to avoid ugliness. In the vast + # majority of cases this rounding will be inconsequential. I hope. + value = round(value.to_compact(), 6) + display_value = jinja2.Markup('{:~P}'.format(value).replace(' ', ' ')) else: - if column in table_metadata.get('units', {}) and value != '': - # Interpret units using pint - value = value * ureg(table_metadata['units'][column]) - # Pint uses floating point which sometimes introduces errors in the compact - # representation, which we have to round off to avoid ugliness. In the vast - # majority of cases this rounding will be inconsequential. I hope. - value = round(value.to_compact(), 6) - display_value = jinja2.Markup('{:~P}'.format(value).replace(' ', ' ')) - else: - display_value = str(value) + display_value = str(value) + cells.append({ 'column': column, 'value': display_value, @@ -577,9 +583,15 @@ class RowTableShared(BaseView): cell_rows.append(cells) if link_column: + # Add the link column header. + # If it's a simple primary key, we have to remove and re-add that column name at + # the beginning of the header row. + if len(pks) == 1: + columns = [col for col in columns if col['name'] != pks[0]] + columns = [{ - 'name': 'Link', - 'sortable': False, + 'name': pks[0] if len(pks) == 1 else 'Link', + 'sortable': len(pks) == 1, }] + columns return columns, cell_rows diff --git a/datasette/static/app.css b/datasette/static/app.css index 21faa7a7..98c4a47c 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -17,6 +17,9 @@ td { padding: 4px; vertical-align: top; } +td.col-link { + font-weight: bold; +} td em { font-style: normal; font-size: 0.8em; diff --git a/datasette/templates/_rows_and_columns.html b/datasette/templates/_rows_and_columns.html index 7e11b2e8..8aa2e657 100644 --- a/datasette/templates/_rows_and_columns.html +++ b/datasette/templates/_rows_and_columns.html @@ -20,7 +20,7 @@ {% for row in display_rows %} {% for cell in row %} - {{ cell.value }} + {{ cell.value }} {% endfor %} {% endfor %} diff --git a/datasette/utils.py b/datasette/utils.py index 1f296a0b..7ba663f0 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -38,14 +38,19 @@ def urlsafe_components(token): ] -def path_from_row_pks(row, pks, use_rowid): +def path_from_row_pks(row, pks, use_rowid, quote=True): + """ Generate an optionally URL-quoted unique identifier + for a row from its primary keys.""" if use_rowid: - return urllib.parse.quote_plus(str(row['rowid'])) - bits = [] - for pk in pks: - bits.append( - urllib.parse.quote_plus(str(row[pk])) - ) + bits = [row['rowid']] + else: + bits = [row[pk] for pk in pks] + + if quote: + bits = [urllib.parse.quote_plus(str(bit)) for bit in bits] + else: + bits = [str(bit) for bit in bits] + return ','.join(bits) diff --git a/tests/test_html.py b/tests/test_html.py index 15ab8d41..e13c8b4f 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -163,16 +163,18 @@ def test_sort_by_desc_redirects(app_client): ]) def test_css_classes_on_body(app_client, path, expected_classes): response = app_client.get(path, gather_request=False) + assert response.status == 200 classes = re.search(r'', response.text).group(1).split() assert classes == expected_classes def test_table_html_simple_primary_key(app_client): response = app_client.get('/test_tables/simple_primary_key', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') ths = table.findAll('th') - assert 'Link' == ths[0].string.strip() - for expected_col, th in zip(('id', 'content'), ths[1:]): + assert 'id' == ths[0].find('a').string.strip() + for expected_col, th in zip(('content',), ths[1:]): a = th.find('a') assert expected_col == a.string assert a['href'].endswith('/simple_primary_key?_sort={}'.format( @@ -181,31 +183,29 @@ def test_table_html_simple_primary_key(app_client): assert ['nofollow'] == a['rel'] assert [ [ - '1', - '1', - 'hello' + '1', + 'hello' ], [ - '2', - '2', - 'world' + '2', + 'world' ], [ - '3', - '3', - '' + '3', + '' ] ] == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] def test_row_html_simple_primary_key(app_client): response = app_client.get('/test_tables/simple_primary_key/1', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') assert [ 'id', 'content' ] == [th.string.strip() for th in table.select('thead th')] assert [ [ - '1', - 'hello' + '1', + 'hello' ] ] == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -218,6 +218,7 @@ def test_table_not_exists(app_client): def test_table_html_no_primary_key(app_client): response = app_client.get('/test_tables/no_primary_key', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') # We have disabled sorting for this table using metadata.json assert [ @@ -225,12 +226,12 @@ def test_table_html_no_primary_key(app_client): ] == [th.string.strip() for th in table.select('thead th')[2:]] expected = [ [ - '{}'.format(i, i), - '{}'.format(i), - '{}'.format(i), - 'a{}'.format(i), - 'b{}'.format(i), - 'c{}'.format(i), + '{}'.format(i, i), + '{}'.format(i), + '{}'.format(i), + 'a{}'.format(i), + 'b{}'.format(i), + 'c{}'.format(i), ] for i in range(1, 51) ] assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -238,17 +239,18 @@ def test_table_html_no_primary_key(app_client): def test_row_html_no_primary_key(app_client): response = app_client.get('/test_tables/no_primary_key/1', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') assert [ 'rowid', 'content', 'a', 'b', 'c' ] == [th.string.strip() for th in table.select('thead th')] expected = [ [ - '1', - '1', - 'a1', - 'b1', - 'c1', + '1', + '1', + 'a1', + 'b1', + 'c1', ] ] assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -256,6 +258,7 @@ def test_row_html_no_primary_key(app_client): def test_table_html_compound_primary_key(app_client): response = app_client.get('/test_tables/compound_primary_key', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') ths = table.findAll('th') assert 'Link' == ths[0].string.strip() @@ -267,10 +270,10 @@ def test_table_html_compound_primary_key(app_client): )) expected = [ [ - 'a,b', - 'a', - 'b', - 'c', + 'a,b', + 'a', + 'b', + 'c', ] ] assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -278,13 +281,13 @@ def test_table_html_compound_primary_key(app_client): def test_table_html_foreign_key_links(app_client): response = app_client.get('/test_tables/foreign_key_references', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') expected = [ [ - '1', - '1', - 'hello\xa01', - '1' + '1', + 'hello\xa01', + '1' ] ] assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -292,15 +295,16 @@ def test_table_html_foreign_key_links(app_client): def test_row_html_compound_primary_key(app_client): response = app_client.get('/test_tables/compound_primary_key/a,b', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') assert [ 'pk1', 'pk2', 'content' ] == [th.string.strip() for th in table.select('thead th')] expected = [ [ - 'a', - 'b', - 'c', + 'a', + 'b', + 'c', ] ] assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -308,20 +312,21 @@ def test_row_html_compound_primary_key(app_client): def test_view_html(app_client): response = app_client.get('/test_tables/simple_view', gather_request=False) + assert response.status == 200 table = Soup(response.body, 'html.parser').find('table') assert [ 'content', 'upper_content' ] == [th.string.strip() for th in table.select('thead th')] expected = [ [ - 'hello', - 'HELLO' + 'hello', + 'HELLO' ], [ - 'world', - 'WORLD' + 'world', + 'WORLD' ], [ - '', - '' + '', + '' ] ] assert expected == [[str(td) for td in tr.select('td')] for tr in table.select('tbody tr')] @@ -329,6 +334,7 @@ def test_view_html(app_client): def test_index_metadata(app_client): response = app_client.get('/', gather_request=False) + assert response.status == 200 soup = Soup(response.body, 'html.parser') assert 'Datasette Title' == soup.find('h1').text assert 'Datasette Description' == inner_html( @@ -339,6 +345,7 @@ def test_index_metadata(app_client): def test_database_metadata(app_client): response = app_client.get('/test_tables', gather_request=False) + assert response.status == 200 soup = Soup(response.body, 'html.parser') # Page title should be the default assert 'test_tables' == soup.find('h1').text @@ -352,6 +359,7 @@ def test_database_metadata(app_client): def test_table_metadata(app_client): response = app_client.get('/test_tables/simple_primary_key', gather_request=False) + assert response.status == 200 soup = Soup(response.body, 'html.parser') # Page title should be custom and should be HTML escaped assert 'This <em>HTML</em> is escaped' == inner_html(soup.find('h1'))