diff --git a/CHANGELOG.md b/CHANGELOG.md index a622fcb4..ac821b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG.md +## Unreleased + +- The table component supports `server_sort_column`. Columns named by this property reload the current page with a `sort_=ASCENDING|DESCENDING` URL parameter when their headers are clicked, enabling database-side sorting before pagination. + ## v0.46.1 - Fixed a regression introduced in v0.46 that could replace a variable with `NULL` while building a value that also used database expressions and `sqlpage.*` functions. For example, this API request could lose `john.doe` and produce a URL ending at `https://api.example.com/`: diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index f93a527e..62487db5 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -949,7 +949,8 @@ Advanced users can apply custom styles to table columns using a CSS class with t INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'table', * FROM (VALUES -- top level - ('sort', 'Make the columns clickable to let the user sort by the value contained in the column.', 'BOOLEAN', TRUE, TRUE), + ('sort', 'Make the columns clickable to let the user sort by the value contained in the column in the browser.', 'BOOLEAN', TRUE, TRUE), + ('server_sort_column', 'A column name, or JSON array of column names, whose headers reload the current page with a sort_=ASCENDING or sort_=DESCENDING URL parameter. Use this for database-side sorting, particularly with pagination. Your SQL query must read the parameter and apply the corresponding ORDER BY safely.', 'JSON', TRUE, TRUE), ('search', 'Add a search bar at the top of the table, letting users easily filter table rows by value.', 'BOOLEAN', TRUE, TRUE), ('initial_search_value', 'Pre-fills the search bar used to filter the table. The user will still be able to edit the value to display table rows that will initially be filtered out.', 'TEXT', TRUE, TRUE), ('search_placeholder', 'Customizes the placeholder text shown in the search input field. Replaces the default "Search..." with text that better describes what users should search for.', 'TEXT', TRUE, TRUE), @@ -1009,6 +1010,25 @@ INSERT INTO example(component, description, properties) VALUES 'table', 'A table with column sorting. Sorting sorts numbers in numeric order, and strings in alphabetical order. +This example uses client-side sorting: it reorders only the rows already loaded in the browser. For paginated data, use `server_sort_column` so that the database sorts before applying `LIMIT` and `OFFSET`. + +## Server-side sorting + +Set `server_sort_column` to the name of a result column. Clicking its header reloads the current page with `sort_=ASCENDING` or `sort_=DESCENDING`. Read that parameter in SQL and use fixed expressions in `ORDER BY`; do not interpolate the parameter into SQL. + +```sql +SELECT ''table'' AS component, + ''name'' AS server_sort_column; + +SELECT id, name +FROM users +ORDER BY + CASE WHEN $sort_name = ''ASCENDING'' THEN name END ASC, + CASE WHEN $sort_name = ''DESCENDING'' THEN name END DESC, + id +LIMIT 100 OFFSET COALESCE(CAST($offset AS INTEGER), 0); +``` + Numbers can be displayed - as raw digits without formatting using the `raw_numbers` property, - as currency using the `money` property to define columns that contain monetary values and `currency` to define the currency, diff --git a/examples/simple-website-example/README.md b/examples/simple-website-example/README.md index df8d5dd3..7b60304e 100644 --- a/examples/simple-website-example/README.md +++ b/examples/simple-website-example/README.md @@ -6,6 +6,7 @@ This website illustrates how to create a basic Create-Read-Update-Delete (CRUD) It has the following bsic features: - Displays a list of user names using the [list component](https://sql-page.com/documentation.sql?component=list#component) (in [`index.sql`](./index.sql#L14-L20)) + - Displays an admin table whose user-name column uses [server-side table sorting](https://sql-page.com/documentation.sql?component=table#component) (in [`table.sql`](./table.sql)) - Add a new user name to the list through a [form](https://sql-page.com/documentation.sql?component=form#component) (in [`index.sql`](./index.sql#L1-L9)) - View a user's personal page by clicking on a name in the list (in [`user.sql`](./user.sql)) - Delete a user from the list by clicking on the delete button in the user's personal page (in [`delete.sql`](./delete.sql)) diff --git a/examples/simple-website-example/table.sql b/examples/simple-website-example/table.sql index 6afa8fb6..fc35b225 100644 --- a/examples/simple-website-example/table.sql +++ b/examples/simple-website-example/table.sql @@ -1,4 +1,13 @@ -select 'table' as component, 'action' as markdown; +-- Sorting this column reloads the current page with sort_username=ASCENDING +-- or sort_username=DESCENDING. The query below applies that choice before +-- rendering the table, so sorting remains correct when the result is paginated. +select 'table' as component, + 'username' as server_sort_column, + 'action' as markdown; select *, format('[Edit](edit.sql?id=%s)', id) as action -from users; \ No newline at end of file +from users +order by + case when $sort_username = 'ASCENDING' then username end asc, + case when $sort_username = 'DESCENDING' then username end desc, + id; diff --git a/examples/simple-website-example/test.hurl b/examples/simple-website-example/test.hurl index 22e87e8f..4d25de98 100644 --- a/examples/simple-website-example/test.hurl +++ b/examples/simple-website-example/test.hurl @@ -14,6 +14,26 @@ body contains "Hurl User" body contains "user.sql?id" body not contains "An error occurred" +POST http://localhost:8080/ +[FormParams] +Username: Hurl Alpha +HTTP 200 +[Asserts] +body contains "Hurl Alpha" +body not contains "An error occurred" + +GET http://localhost:8080/table.sql +HTTP 200 +[Asserts] +xpath "string(//button[@data-server-sort-column='username']/@data-server-sort-column)" == "username" +body not contains "An error occurred" + +GET http://localhost:8080/table.sql?sort_username=ASCENDING +HTTP 200 +[Asserts] +xpath "normalize-space(string(//tbody/tr[1]/td[2]))" == "Hurl Alpha" +body not contains "An error occurred" + GET http://localhost:8080/user.sql?id=1 HTTP 200 [Asserts] diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index e32667b8..c24faf78 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -31,17 +31,24 @@ function setup_table(root_el) { const table_el = root_el.querySelector("table"); if (!table_el) return; /** @type {NodeListOf} */ - const sort_button_els = table_el.querySelectorAll("button.sort[data-sort]"); + const sort_button_els = table_el.querySelectorAll("button.sort"); const sort_buttons = [...sort_button_els]; + const client_sort_buttons = sort_buttons.filter( + (button) => !button.dataset.serverSortColumn, + ); + const server_sort_buttons = sort_buttons.filter( + (button) => !!button.dataset.serverSortColumn, + ); const item_parent = table_el.querySelector("tbody"); - const has_sort = sort_buttons.length > 0; + const has_client_sort = client_sort_buttons.length > 0; - if (search_input || has_sort) { - const items = table_parse_data(table_el, sort_buttons); + if (search_input || has_client_sort) { + const items = table_parse_data(table_el, client_sort_buttons); if (search_input) setup_table_search_behavior(search_input, items); - if (has_sort && item_parent) - setup_sort_behavior(sort_buttons, items, item_parent); + if (has_client_sort && item_parent) + setup_sort_behavior(client_sort_buttons, items, item_parent); } + setup_server_sort_behavior(server_sort_buttons); // Change number format AFTER parsing and storing the sort keys apply_number_formatting(table_el); @@ -159,6 +166,36 @@ function setup_sort_behavior(sort_buttons, items, item_parent) { }); } +/** + * Reloads the current page with the selected server-side sort column and direction. + * @param {HTMLElement[]} sort_buttons + */ +function setup_server_sort_behavior(sort_buttons) { + const current_url = new URL(window.location.href); + for (const button of sort_buttons) { + const column = button.dataset.serverSortColumn; + if (!column) continue; + + const parameter = `sort_${column}`; + const direction = current_url.searchParams.get(parameter); + if (direction === "ASCENDING") button.classList.add("asc"); + if (direction === "DESCENDING") button.classList.add("desc"); + + button.addEventListener("click", () => { + const url = new URL(window.location.href); + const next_direction = + url.searchParams.get(parameter) === "ASCENDING" + ? "DESCENDING" + : "ASCENDING"; + for (const key of [...url.searchParams.keys()]) { + if (key.startsWith("sort_")) url.searchParams.delete(key); + } + url.searchParams.set(parameter, next_direction); + window.location.assign(url.toString()); + }); + } +} + function sqlpage_table() { /** @type {NodeListOf} */ const tables = document.querySelectorAll("[data-pre-init=table]"); diff --git a/sqlpage/templates/table.handlebars b/sqlpage/templates/table.handlebars index a40c6b1e..ec70048b 100644 --- a/sqlpage/templates/table.handlebars +++ b/sqlpage/templates/table.handlebars @@ -45,7 +45,9 @@ {{~#if (array_contains_case_insensitive ../../raw_numbers @key)}} data-raw_number="1"{{/if~}} {{~#if (array_contains_case_insensitive ../../money @key)}} data-money="1"{{/if~}} > - {{~#if ../../sort~}} + {{~#if (array_contains_case_insensitive (to_array ../../server_sort_column) @key)~}} + + {{~else if ../../sort~}} {{~else~}} {{~@key~}} diff --git a/tests/end-to-end/fixtures/table-server-sort/index.sql b/tests/end-to-end/fixtures/table-server-sort/index.sql new file mode 100644 index 00000000..05fe51d8 --- /dev/null +++ b/tests/end-to-end/fixtures/table-server-sort/index.sql @@ -0,0 +1,12 @@ +SELECT 'table' AS component, + 'name' AS server_sort_column; + +SELECT name +FROM ( + SELECT 'Zulu' AS name + UNION ALL SELECT 'Alpha' + UNION ALL SELECT 'Mike' +) +ORDER BY + CASE WHEN $sort_name = 'ASCENDING' THEN name END ASC, + CASE WHEN $sort_name = 'DESCENDING' THEN name END DESC; diff --git a/tests/end-to-end/fixtures/table-server-sort/test.ts b/tests/end-to-end/fixtures/table-server-sort/test.ts new file mode 100644 index 00000000..2167fc71 --- /dev/null +++ b/tests/end-to-end/fixtures/table-server-sort/test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "../../fixture"; + +test("server-sort columns reload the page in ascending and descending order", async ({ + page, +}) => { + const sortButton = page.getByRole("button", { name: "name" }); + + await sortButton.click(); + await expect(page).toHaveURL(/sort_name=ASCENDING/); + await expect(page.locator("tbody tr").first()).toHaveText("Alpha"); + await expect(sortButton).toHaveClass(/\basc\b/); + + await sortButton.click(); + await expect(page).toHaveURL(/sort_name=DESCENDING/); + await expect(page.locator("tbody tr").first()).toHaveText("Zulu"); + await expect(sortButton).toHaveClass(/\bdesc\b/); +});