diff --git a/.gitignore b/.gitignore index a1a6898f..9cf4a906 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_STORE /target sqlpage.db .idea/ diff --git a/examples/official-site/blog/pagination.png b/examples/official-site/blog/pagination.png new file mode 100644 index 00000000..4c4137ea Binary files /dev/null and b/examples/official-site/blog/pagination.png differ diff --git a/examples/official-site/sqlpage/migrations/70_pagination.sql b/examples/official-site/sqlpage/migrations/70_pagination.sql new file mode 100644 index 00000000..5bb50d01 --- /dev/null +++ b/examples/official-site/sqlpage/migrations/70_pagination.sql @@ -0,0 +1,252 @@ +INSERT INTO component(name, icon, description, introduced_in_version) VALUES + ('pagination', 'sailboat-2', ' +Navigation links to go to the first, previous, next, or last page of a dataset. +Useful when data is divided into pages, each containing a fixed number of rows. + +This component only handles the display of pagination. +**Your sql queries are responsible for filtering data** based on the page number passed as a URL parameter. + +This component is typically used in conjunction with a [table](?component=table), +[list](?component=list), or [card](?component=card) component. + +The pagination component displays navigation buttons (first, previous, next, last) customizable with text or icons. + +For large numbers of pages, an offset can limit the visible page links. + +A minimal example of a SQL query that uses the pagination would be: +```sql +select ''table'' as component; +select * from my_table limit 100 offset $offset; + +select ''pagination'' as component; +with recursive pages as ( + select 0 as offset + union all + select offset + 100 from pages + where offset + 100 < (select count(*) from my_table) +) +select + (offset/100+1) as contents, + sqlpage.link(sqlpage.path(), json_object(''offset'', offset)) as link, + offset = coalesce(cast($offset as integer), 0) as active +from pages; +``` + +For more advanced usage, the [pagination guide](blog.sql?post=How+to+use+the+pagination+component) provides a complete tutorial. +', '0.40.0'); + +INSERT INTO parameter(component, name, description, type, top_level, optional) SELECT 'pagination', * FROM (VALUES + -- Top-level parameters + ('first_link','A target URL to which the user should be directed to get to the first page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('previous_link','A target URL to which the user should be directed to get to the previous page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('next_link','A target URL to which the user should be directed to get to the next page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('last_link','A target URL to which the user should be directed to get to the last page. If none, the link is not displayed.','URL',TRUE,TRUE), + ('first_title','The text displayed on the button to go to the first page.','TEXT',TRUE,TRUE), + ('previous_title','The text displayed on the button to go to the previous page.','TEXT',TRUE,TRUE), + ('next_title','The text displayed on the button to go to the next page.','TEXT',TRUE,TRUE), + ('last_title','The text displayed on the button to go to the last page.','TEXT',TRUE,TRUE), + ('first_disabled','disables the button to go to the first page.','BOOLEAN',TRUE,TRUE), + ('previous_disabled','disables the button to go to the previous page.','BOOLEAN',TRUE,TRUE), + ('next_disabled','Disables the button to go to the next page.','BOOLEAN',TRUE,TRUE), + ('last_disabled','disables the button to go to the last page.','BOOLEAN',TRUE,TRUE), + ('outline','Whether to use outline version of the pagination.','BOOLEAN',TRUE,TRUE), + ('circle','Whether to use circle version of the pagination.','BOOLEAN',TRUE,TRUE), + -- Item-level parameters (for each page) + ('contents','Page number.','INTEGER',FALSE,FALSE), + ('link','A target URL to which the user should be redirected to view the requested page of data.','URL',FALSE,TRUE), + ('offset','Whether to use offset to show only a few pages at a time. Usefull if the count of pages is too large. Defaults to false','BOOLEAN',FALSE,TRUE), + ('active','Whether the link is active or not. Defaults to false.','BOOLEAN',FALSE,TRUE) +) x; + + +-- Insert example(s) for the component +INSERT INTO example(component, description, properties) +VALUES ( + 'pagination', + 'This is an extremely simple example of a pagination component that displays only the page numbers, with the first page being the current page.', + JSON( + '[ + { + "component": "pagination" + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'The ouline style adds a rectangular border to each navigation link.', + JSON( + '[ + { + "component": "pagination", + "outline": true + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'The circle style adds a circular border to each navigation link.', + JSON( + '[ + { + "component": "pagination", + "circle": true + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'The following example implements navigation links that can be enabled or disabled as needed. Since a navigation link does not appear if no link is assigned to it, you must always assign a link to display it as disabled.', + JSON( + '[ + { + "component": "pagination", + "first_link": "?component=pagination", + "first_disabled": true, + "previous_link": "?component=pagination", + "previous_disabled": true, + "next_link": "#?page=2", + "last_link": "#?page=3" + + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'Instead of using icons, you can apply text to the navigation links.', + JSON( + '[ + { + "component": "pagination", + "first_title": "First", + "last_title": "Last", + "previous_title": "Previous", + "next_title": "Next", + "first_link": "?component=pagination", + "first_disabled": true, + "previous_link": "?component=pagination", + "previous_disabled": true, + "next_link": "#?page=2", + "last_link": "#?page=3" + + }, + { + "contents": 1, + "link": "?component=pagination&page=1", + "active": true + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + } + ]' + ) + ), + ( + 'pagination', + 'If you have a large number of pages to display, you can use an offset to represent a group of pages.', + JSON( + '[ + { + "component": "pagination", + "first_link": "#?page=1", + "previous_link": "#?page=3", + "next_link": "#?page=4", + "last_link": "#?page=99" + + }, + { + "contents": 1, + "link": "?component=pagination&page=1" + }, + { + "contents": 2, + "link": "?component=pagination&page=2" + }, + { + "contents": 3, + "link": "?component=pagination&page=3" + }, + { + "contents": 4, + "link": "?component=pagination&page=4", + "active": true + }, + { + "contents": 5, + "link": "?component=pagination&page=5" + }, + { + "contents": 6, + "link": "?component=pagination&page=6" + }, + { + "offset": true + }, + { + "contents": 99, + "link": "?component=pagination&page=99" + }, + ]' + ) + ); + \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/71_blog_pagination.sql b/examples/official-site/sqlpage/migrations/71_blog_pagination.sql new file mode 100644 index 00000000..859b9e8c --- /dev/null +++ b/examples/official-site/sqlpage/migrations/71_blog_pagination.sql @@ -0,0 +1,163 @@ + +INSERT INTO blog_posts (title, description, icon, created_at, content) +VALUES + ( + 'How to use the pagination component', + 'A tutorial for using the pagination component', + 'sailboat-2', + '2025-11-10', + ' +# How to use the pagination component + +To display a large number of records from a database, it is often practical to split these data into pages. The user can thus navigate from one page to another, as well as directly to the first or last page. With SQLPage, it is possible to perform these operations using the pagination component. + +This component offers many options, and I recommend consulting its documentation before proceeding with the rest of this tutorial. + +Of course, this component only handles its display and does not implement any logic for data processing or state changes. In this tutorial, we will implement a complete example of using the pagination component with a SQLite database, but the code should work without modification (or with very little modification) with any relational database management system (RDBMS). + +> This article serves as a tutorial on the pagination component, rather than an advanced guide on paginated data retrieval from a database. The document employs a straightforward approach using the LIMIT and OFFSET instructions. This approach is interesting only for datasets that are big enough not to be realistically loadable on a single webpage, yet small enough for being queryable with OFFSET...LIMIT. + +## Initialization + +We first need to define two constants that indicate the maximum number of rows per page and the maximum number of pages that the component should display. + +``` +SET MAX_RECORD_PER_PAGE = 10; +SET MAX_PAGES = 10; +``` + +Now, we need to know the number of rows present in the table to be displayed. We can then calculate the number of pages required. + +``` +SET records_count = (SELECT COUNT(*) FROM album); +SET pages_count = (CAST($records_count AS INTEGER) / CAST($MAX_RECORD_PER_PAGE AS INTEGER)); +``` + +It is possible that the number of rows in the table is greater than the estimated number of pages multiplied by the number of rows per page. In this case, it is necessary to add an additional page. + +``` +SET pages_count = ( + CASE + WHEN MOD(CAST($records_count AS INTEGER),CAST($MAX_RECORD_PER_PAGE AS INTEGER)) = 0 THEN $pages_count + ELSE (CAST($pages_count AS INTEGER) + 1) + END +); +``` + +We will need to transmit the page number to be displayed in the URL using the `page` parameter. We do the same for the number of the first page (`idx_page`) appearing at the left end of the pagination component. + +![Meaning of URL parameters](blog/pagination.png) + + +If the page number or index is not present in the URL, the value of 1 is applied by default. + +``` +SET page = COALESCE($page,1); +SET idx_page = COALESCE($idx_page,1); +``` + +## Read the data + +We can now read and display the data based on the active page. To do this, we simply use a table component. + +``` +SELECT + ''table'' as component +SELECT + user_id AS id, + last_name AS "Last name", + first_name AS "First name" +FROM + users +LIMIT CAST($MAX_RECORD_PER_PAGE AS INTEGER) +OFFSET (CAST($page AS INTEGER) - 1) * CAST($MAX_RECORD_PER_PAGE AS INTEGER); +``` + +The SQL LIMIT clause allows us to not read more rows than the maximum allowed for a page. With the SQL OFFSET clause, we specify from which row the data is selected. + +On each HTML page load, the table content will be updated based on the `page` and `idx_page` variables, whose values will be extracted from the URL + +## Set up the pagination component + +Now, we need to set up the parameters that will be included in the URL for the buttons to navigate to the previous or next page. + +If the user wants to view the previous page and the current page is not the first one, the value of the `page` variable is decremented. The same applies to `idx_page`, which is decremented if its value does not correspond to the first page. + +``` +SET previous_parameters = ( + CASE + WHEN CAST($page AS INTEGER) > 1 THEN + json_object( + ''page'', (CAST($page AS INTEGER) - 1), + ''idx_page'', (CASE + WHEN CAST($idx_page AS INTEGER) > 1 THEN (CAST($idx_page AS INTEGER) - 1) + ELSE $idx_page + END) + ) + ELSE json_object() END +); +``` + +The logic is quite similar for the URL to view the next page. First, it is necessary to verify that the user is not already on the last page. Then, the `page` variable can be incremented and the `idx_page` variable updated. + +``` +SET next_parameters = ( + CASE + WHEN CAST($page AS INTEGER) < CAST($pages_count AS INTEGER) THEN + json_object( + ''page'', (CAST($page AS INTEGER) + 1), + ''idx_page'', (CASE + WHEN CAST($idx_page AS INTEGER) < (CAST($pages_count AS INTEGER) - CAST($MAX_PAGES AS INTEGER) + 1) THEN (CAST($idx_page AS INTEGER) + 1) + ELSE $idx_page + END) + ) + ELSE json_object() END +); +``` + +We can now add the pagination component, which is placed below the table displaying the data. All the logic for managing the buttons is entirely handled in SQL: +- the buttons to access the first or last page, +- the buttons to view the previous or next page, +- the enabling or disabling of these buttons based on the context. + +``` +SELECT + ''pagination'' AS component, + (CAST($page AS INTEGER) = 1) AS first_disabled, + (CAST($page AS INTEGER) = 1) AS previous_disabled, + (CAST($page AS INTEGER) = CAST($pages_count AS INTEGER)) AS next_disabled, + (CAST($page AS INTEGER) = CAST($pages_count AS INTEGER)) AS last_disabled, + sqlpage.link(sqlpage.path(), json_object(''page'', 1, ''idx_page'', 1)) as first_link, + sqlpage.link(sqlpage.path(), $previous_parameters) AS previous_link, + sqlpage.link(sqlpage.path(), $next_parameters) AS next_link, + sqlpage.link( + sqlpage.path(), + json_object(''page'', $pages_count, ''idx_page'', ( + CASE + WHEN (CAST($pages_count AS INTEGER) <= CAST($MAX_PAGES AS INTEGER)) THEN 1 + ELSE (CAST($pages_count AS INTEGER) - CAST($MAX_PAGES AS INTEGER) + 1) + END) + ) + ) AS last_link, + TRUE AS outline; +``` + +The final step is to generate the page numbers based on the number of pages and the index of the first page displayed to the left of the component. To do this, we use a recursive CTE query. + +``` +WITH RECURSIVE page_numbers AS ( + SELECT $idx_page AS number + UNION ALL + SELECT number + 1 + FROM page_numbers + LIMIT CAST($MAX_PAGES AS INTEGER) +) +SELECT + number AS contents, + sqlpage.link(sqlpage.path(), json_object(''page'', number, ''idx_page'', $idx_page)) as link, + (number = CAST($page AS INTEGER)) AS active +FROM page_numbers; +``` + +If the added page matches the content of the `page` variable, the `active` option is set to `TRUE` so that the user knows it is the current page. +'); \ No newline at end of file diff --git a/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql b/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql index df054541..a5d438c8 100644 --- a/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql +++ b/examples/official-site/sqlpage/migrations/99_shared_id_class_attributes.sql @@ -19,7 +19,8 @@ FROM (VALUES ('tracking', TRUE), ('text', TRUE), ('carousel', TRUE), - ('login', TRUE) + ('login', TRUE), + ('pagination', TRUE) ); INSERT INTO parameter(component, top_level, name, description, type, optional) @@ -51,6 +52,7 @@ FROM (VALUES ('title', TRUE), ('tracking', TRUE), ('carousel', TRUE), - ('login', TRUE) + ('login', TRUE), + ('pagination', TRUE) ); diff --git a/sqlpage/templates/pagination.handlebars b/sqlpage/templates/pagination.handlebars new file mode 100644 index 00000000..c3cf29a4 --- /dev/null +++ b/sqlpage/templates/pagination.handlebars @@ -0,0 +1,51 @@ +
+
+ +
+