From aee9ebe12889ba4e71d2532212fd44ef6da5b212 Mon Sep 17 00:00:00 2001 From: Francesco Cattoglio Date: Tue, 11 Feb 2025 10:42:15 +0100 Subject: [PATCH 1/2] Added an option to format numbers using `toLocaleString()` for table columns --- sqlpage/sqlpage.js | 17 ++++++++++++++++- sqlpage/templates/table.handlebars | 5 ++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index 313e1b8d..f9af3a44 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -35,7 +35,22 @@ function table_search_sort(el) { el, sort_keys: sort_buttons.map((b, idx) => { const sort_key = cells[idx]?.textContent; - return { num: Number.parseFloat(sort_key), str: sort_key }; + const num = Number.parseFloat(sort_key); + // if the user requested for this column to be formatted using `toLocaleString()`, + // we replace the cell contents + if (cells[idx]?.hasAttribute("number-format-locale") && !Number.isNaN(num)) { + const digits = cell.getAttribute("number-format-digits"); + // The variable `digits` can be left empty or contain an integer + const options = digits + ? { minimumFractionDigits: digits, maximumFractionDigits: digits } + : {}; + // Use the host default language, with the options we just defined + cell.innerHTML = num.toLocaleString(undefined, options); + } + return { + num, + str: sort_key, + }; }), }; }); diff --git a/sqlpage/templates/table.handlebars b/sqlpage/templates/table.handlebars index 33c38c38..55b1db6d 100644 --- a/sqlpage/templates/table.handlebars +++ b/sqlpage/templates/table.handlebars @@ -53,7 +53,10 @@ + " + {{~#if (array_contains_case_insensitive ../../number_format_locale @key)}} number-format-locale {{/if~}} + number-format-digits={{ ../../number_format_digits }} + > {{~#if (array_contains_case_insensitive ../../markdown @key)~}} {{{markdown this}}} {{~else~}} From ebeddf1970c99321d7bb257ae1510a66abb220c7 Mon Sep 17 00:00:00 2001 From: lovasoa Date: Tue, 11 Feb 2025 23:37:22 +0100 Subject: [PATCH 2/2] format numbers in tables --- CHANGELOG.md | 16 +++++-- .../sqlpage/migrations/01_documentation.sql | 22 ++++++--- sqlpage/sqlpage.js | 46 +++++++++++-------- sqlpage/templates/table.handlebars | 17 ++++--- tests/end-to-end/official-site.spec.ts | 4 +- tests/end-to-end/package-lock.json | 28 +++++------ 6 files changed, 84 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a680d02..f778c71e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ - Add a new `auto_submit` parameter to the form component. When set to true, the form will be automatically submitted when the user changes any of its fields, and the page will be reloaded with the new value. The validation button is removed. - This is useful to quickly create filters at the top of a dashboard or report page, that will be automatically applied when the user changes them. - New `options_source` parameter in the form component. This allows to dynamically load options for dropdowns from a different SQL file. - - This allows easily implementing autocomplete for form fields with a large number of possible options. - - In the map component, add support for map pins with a description but no title. + - This allows easily implementing autocomplete for form fields with a large number of possible options. +- In the map component, add support for map pins with a description but no title. - Improved error messages when a parameter of a sqlpage function is invalid. Error traces used to be truncated, and made it hard to understand the exact cause of the error in some cases. In particular, calls to `sqlpage.fetch` would display an unhelpful error message when the HTTP request definition was invalid. `sqlpage.fetch` now also throws an error if the HTTP request definition contains unknown fields. - Make the `headers` field of the `sqlpage.fetch` function parameter optional. It defaults to sending a User-Agent header containing the SQLPage version. - Make custom layout creations with the `card` component easier and less error-prone: @@ -23,6 +23,16 @@ - When an embedded page is rendered, the `shell` component is automatically replaced by a `shell-empty` component, to avoid displaying a duplicate shell and creating invalid duplicated page metadata in the response. - Update Tabler Icons to version [3.30.0](https://tabler.io/changelog#/changelog/tabler-icons-3.30), with many new icons. - Update the CSS framework to [Tabler 1.0.0](https://github.com/tabler/tabler/releases/tag/v1.0.0), with many small UI consistency improvements. +- Add native number formatting to the table component. Numeric values in tables are now formatted in the visitor's locale by default, using country-specific thousands separators and decimal points. + - This is better than formatting numbers inside the database, because + - columns are sorted correctly in the numeric order by default, instead of being sorted in the alphabetic order of the formatted string. + - the formatted numbers are more readable for the user by default, without requiring any additional code. + - it adapts to the visitor's preferred locale, for instance using `.` as a decimal point and a space as a thousands separator if the visitor is in France. + - less data is sent from the database to sqlpage, and from sqlpage to the client, because the numbers are not formatted directly in the database. + - Add new customization properties to the table component: + - Switch back to displaying raw numbers without formatting using the `raw_numbers` property. + - Format monetary values using the `money` property to specify columns and `currency` to set the currency. + - Control decimal places with `number_format_digits` property. ## 0.32.1 (2025-01-03) @@ -118,7 +128,7 @@ This is a bugfix release. - Added support for: - advanced `JSON_TABLE` usage in MySQL for working with JSON arrays. - `EXECUTE` statements with parameters in MSSQL for running stored procedures. - - MSSQL’s `TRY_CONVERT` function for type conversion. + - MSSQL's `TRY_CONVERT` function for type conversion. - `ANY`, `ALL`, and `SOME` subqueries (e.g., `SELECT * FROM t WHERE a = ANY (SELECT b FROM t2)`). - `LIMIT max_rows, offset` syntax in SQLite. - Assigning column names aliases using `=` in MSSQL (e.g., `SELECT col_name = value`). diff --git a/examples/official-site/sqlpage/migrations/01_documentation.sql b/examples/official-site/sqlpage/migrations/01_documentation.sql index b19a35cb..04759c70 100644 --- a/examples/official-site/sqlpage/migrations/01_documentation.sql +++ b/examples/official-site/sqlpage/migrations/01_documentation.sql @@ -780,10 +780,14 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S ('border', 'Whether to draw borders on all sides of the table and cells.', 'BOOLEAN', TRUE, TRUE), ('overflow', 'Whether to to let "wide" tables overflow across the right border and enable browser-based horizontal scrolling.', 'BOOLEAN', TRUE, TRUE), ('small', 'Whether to use compact table.', 'BOOLEAN', TRUE, TRUE), - ('description','Description of the table content and helps users with screen readers to find a table and understand what it’s.','TEXT',TRUE,TRUE), + ('description','Description of the table contents. Helps users with screen readers to find a table and understand what it’s about.','TEXT',TRUE,TRUE), ('empty_description', 'Text to display if the table does not contain any row. Defaults to "no data".', 'TEXT', TRUE, TRUE), ('freeze_columns', 'Whether to freeze the leftmost column of the table.', 'BOOLEAN', TRUE, TRUE), ('freeze_headers', 'Whether to freeze the top row of the table.', 'BOOLEAN', TRUE, TRUE), + ('raw_numbers', 'Name of a column whose values are numeric, but should be displayed as raw numbers without any formatting (no thousands separators, decimal separator is always a dot). This argument can be repeated multiple times.', 'TEXT', TRUE, TRUE), + ('money', 'Name of a numeric column whose values should be displayed as currency amounts, in the currency defined by the `currency` property. This argument can be repeated multiple times.', 'TEXT', TRUE, TRUE), + ('currency', 'The ISO 4217 currency code (e.g., USD, EUR, GBP, etc.) to use when formatting monetary values.', 'TEXT', TRUE, TRUE), + ('number_format_digits', 'Maximum number of decimal digits to display for numeric values.', 'INTEGER', TRUE, TRUE), -- row level ('_sqlpage_css_class', 'For advanced users. Sets a css class on the table row. Added in v0.8.0.', 'TEXT', FALSE, TRUE), ('_sqlpage_color', 'Sets the background color of the row. Added in v0.8.0.', 'COLOR', FALSE, TRUE), @@ -804,12 +808,18 @@ INSERT INTO example(component, description, properties) VALUES ]')), ( 'table', - 'A table with column sorting. Sorting sorts numbers in numeric order, and strings in alphabetical order.', + 'A table with column sorting. Sorting sorts numbers in numeric order, and strings in alphabetical order. + +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, + - as numbers with a fixed maximum number of decimal digits using the `number_format_digits` property. +', json( - '[{"component":"table", "sort": true, "align_right": ["Price ($)", "Amount in stock"], "align_center": ["part_no"] }, - {"id": 31456, "part_no": "SQL-TABLE-856-G", "Price ($)": 12, "Amount in stock": 5}, - {"id": 996, "part_no": "SQL-FORMS-86-M", "Price ($)": 1, "Amount in stock": 15}, - {"id": 131456, "part_no": "SQL-CARDS-56-K", "Price ($)": 127, "Amount in stock": 9} + '[{"component":"table", "sort": true, "align_right": ["Price", "Amount in stock"], "align_center": ["part_no"], "raw_numbers": ["id"], "currency": "USD", "money": ["Price"] }, + {"id": 31456, "part_no": "SQL-TABLE-856-G", "Price": 12, "Amount in stock": 5}, + {"id": 996, "part_no": "SQL-FORMS-86-M", "Price": 1, "Amount in stock": 1234}, + {"id": 131456, "part_no": "SQL-CARDS-56-K", "Price": 127, "Amount in stock": 98} ]' )), ( diff --git a/sqlpage/sqlpage.js b/sqlpage/sqlpage.js index f9af3a44..a8105f9e 100644 --- a/sqlpage/sqlpage.js +++ b/sqlpage/sqlpage.js @@ -23,29 +23,37 @@ function sqlpage_card() { } } -/** @param {HTMLElement} el */ -function table_search_sort(el) { +/** @param {HTMLElement} root_el */ +function table_search_sort(root_el) { /** @type {HTMLInputElement | null} */ - const search_input = el.querySelector("input.search"); - const sort_buttons = [...el.querySelectorAll("button.sort[data-sort]")]; - const item_parent = el.querySelector("tbody"); - const items = [...item_parent.querySelectorAll("tr")].map((el) => { - const cells = el.getElementsByTagName("td"); + const search_input = root_el.querySelector("input.search"); + const table_el = root_el.querySelector("table"); + const sort_buttons = [...table_el.querySelectorAll("button.sort[data-sort]")]; + const item_parent = table_el.querySelector("tbody"); + const number_format_locale = table_el.dataset.number_format_locale; + const number_format_digits = table_el.dataset.number_format_digits; + const currency = table_el.dataset.currency; + const header_els = table_el.querySelectorAll("thead > tr > th"); + const col_types = [...header_els].map((el) => el.dataset.column_type); + const col_rawnums = [...header_els].map((el) => !!el.dataset.raw_number); + const col_money = [...header_els].map((el) => !!el.dataset.money); + const items = [...item_parent.querySelectorAll("tr")].map((tr_el) => { + const cells = tr_el.getElementsByTagName("td"); return { - el, - sort_keys: sort_buttons.map((b, idx) => { + el: tr_el, + sort_keys: sort_buttons.map((btn_el, idx) => { const sort_key = cells[idx]?.textContent; + const column_type = col_types[idx]; const num = Number.parseFloat(sort_key); - // if the user requested for this column to be formatted using `toLocaleString()`, - // we replace the cell contents - if (cells[idx]?.hasAttribute("number-format-locale") && !Number.isNaN(num)) { - const digits = cell.getAttribute("number-format-digits"); - // The variable `digits` can be left empty or contain an integer - const options = digits - ? { minimumFractionDigits: digits, maximumFractionDigits: digits } - : {}; - // Use the host default language, with the options we just defined - cell.innerHTML = num.toLocaleString(undefined, options); + const is_raw_number = col_rawnums[idx]; + if (column_type === "number" && !is_raw_number) { + const cell_el = cells[idx]; + const is_money = col_money[idx]; + cell_el.textContent = num.toLocaleString(number_format_locale, { + maximumFractionDigits: number_format_digits, + currency, + style: is_money ? "currency" : undefined, + }); } return { num, diff --git a/sqlpage/templates/table.handlebars b/sqlpage/templates/table.handlebars index 55b1db6d..3f78224c 100644 --- a/sqlpage/templates/table.handlebars +++ b/sqlpage/templates/table.handlebars @@ -21,7 +21,11 @@ {{~#if hover}} table-hover {{/if~}} {{~#if border}} table-bordered {{/if~}} {{~#if small}} table-sm {{/if~}} - "> + " + {{~#if number_format_locale}} data-number_format_locale="{{number_format_locale}}"{{/if~}} + {{~#if number_format_digits}} data-number_format_digits="{{number_format_digits}}"{{/if~}} + {{~#if currency}} data-currency="{{currency}}"{{/if~}} + > {{#if description}}{{description}}{{/if}} {{#each_row}} {{#if (eq @row_index 0)}} @@ -33,7 +37,11 @@ {{~@key~}} {{~#if (array_contains_case_insensitive ../../align_right @key)}} text-end {{/if~}} {{~#if (array_contains_case_insensitive ../../align_center @key)}} text-center {{/if~}} - "> + " + data-column_type="{{typeof this}}" + {{~#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~}} {{~else~}} @@ -53,10 +61,7 @@ + "> {{~#if (array_contains_case_insensitive ../../markdown @key)~}} {{{markdown this}}} {{~else~}} diff --git a/tests/end-to-end/official-site.spec.ts b/tests/end-to-end/official-site.spec.ts index afa635c0..c39705e0 100644 --- a/tests/end-to-end/official-site.spec.ts +++ b/tests/end-to-end/official-site.spec.ts @@ -121,7 +121,9 @@ test("table sorting", async ({ page }) => { // Test amount in stock column sorting await tableSection.getByRole("button", { name: "Amount in stock" }).click(); const amounts = await tableSection.locator("td.Amount").allInnerTexts(); - const numericAmounts = amounts.map((amount) => Number.parseInt(amount)); + const numericAmounts = amounts.map((amount) => + Number.parseInt(amount.replace(/[^0-9]/g, "")), + ); const sortedAmounts = [...numericAmounts].sort((a, b) => a - b); expect(numericAmounts).toEqual(sortedAmounts); }); diff --git a/tests/end-to-end/package-lock.json b/tests/end-to-end/package-lock.json index 3285932c..b5a5b959 100644 --- a/tests/end-to-end/package-lock.json +++ b/tests/end-to-end/package-lock.json @@ -14,13 +14,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz", - "integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz", + "integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.49.1" + "playwright": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -30,9 +30,9 @@ } }, "node_modules/@types/node": { - "version": "22.10.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", - "integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", + "version": "22.13.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", + "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", "dev": true, "license": "MIT", "dependencies": { @@ -55,13 +55,13 @@ } }, "node_modules/playwright": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz", - "integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz", + "integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.49.1" + "playwright-core": "1.50.1" }, "bin": { "playwright": "cli.js" @@ -74,9 +74,9 @@ } }, "node_modules/playwright-core": { - "version": "1.49.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz", - "integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==", + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz", + "integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==", "dev": true, "license": "Apache-2.0", "bin": {