chore: import upstream snapshot with attribution
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# Custom form component
|
||||
|
||||
This example shows how to create a simple custom component in handlebars, and call it from SQL.
|
||||
|
||||
It uses MySQL, but it should be easy to adapt to other databases.
|
||||
The only MySQL-specific features used here are:
|
||||
- `json_table`, which is supported by MariaDB and MySQL 8.0 and later,
|
||||
- MySQL's `json_merge` function.
|
||||
|
||||
Both [have analogs in other databases](https://sql-page.com/blog.sql?post=JSON%20in%20SQL%3A%20A%20Comprehensive%20Guide).
|
||||
|
||||

|
||||
|
||||
|
||||
## Key features illustrated in this example
|
||||
|
||||
- How to create a custom component in handlebars, with dynamic behavior implemented in JavaScript
|
||||
- How to manage multiple-option select boxes, with pre-selected items, and multiple choices
|
||||
- Including a common menu between different pages using a `shell.sql` file, the dynamic component, and the `sqlpage.run_sql` function.
|
||||
@@ -0,0 +1,40 @@
|
||||
select
|
||||
'dynamic' as component,
|
||||
sqlpage.run_sql ('shell.sql') as properties;
|
||||
|
||||
-- this does the same thing as index.sql, but uses the normal form component instead of our fancy dual-list component
|
||||
select
|
||||
'form' as component,
|
||||
'form_action.sql' as action;
|
||||
|
||||
select
|
||||
'select' as type,
|
||||
true as searchable,
|
||||
true as multiple,
|
||||
'selected_items[]' as name,
|
||||
'Users in this group' as label,
|
||||
-- JSON_MERGE combines two JSON documents:
|
||||
-- 1. A JSON object with an empty label
|
||||
-- 2. An array of user objects created by JSON_ARRAYAGG
|
||||
JSON_MERGE (
|
||||
-- Creates a simple JSON object with a single empty property {"label": ""}
|
||||
JSON_OBJECT ('label', ''),
|
||||
-- JSON_ARRAYAGG takes multiple rows and combines them into a JSON array
|
||||
-- Each element in the array is a JSON object created by json_object()
|
||||
JSON_ARRAYAGG (
|
||||
-- Creates a JSON object for each user with:
|
||||
-- - {"label": "the user's name", "value": "the user's ID", "selected": true } (if the user is in the group)
|
||||
json_object (
|
||||
'label',
|
||||
users.name,
|
||||
'value',
|
||||
users.id,
|
||||
'selected',
|
||||
group_members.group_id is not null -- the left join creates NULLs for users not in the group
|
||||
)
|
||||
)
|
||||
) as options
|
||||
from
|
||||
users
|
||||
left join group_members on users.id = group_members.user_id
|
||||
and group_members.group_id = 1;
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- ./sqlpage:/etc/sqlpage
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: mysql://root:secret@db/sqlpage
|
||||
db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases
|
||||
image: mysql:9 # support for json_table was added in mariadb 10.6
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: secret
|
||||
MYSQL_DATABASE: sqlpage
|
||||
@@ -0,0 +1,28 @@
|
||||
-- remove all existing members from this group
|
||||
delete from group_members where group_id = 1;
|
||||
|
||||
-- add the selected members to this group
|
||||
-- This query takes a JSON array and converts it to rows
|
||||
-- :selected_items contains a JSON array of user IDs, e.g. ["1", "2", "3"], generated by SQLPage from the multiple-select box answers posted by the browser
|
||||
-- json_table breaks down the JSON array into individual rows
|
||||
-- '$[*]' means "look at each element in the root array"
|
||||
-- columns (id int path '$') extracts each array element as an integer into a column named 'id'
|
||||
-- The result is a temporary table with one integer column (id) and one row per array element
|
||||
insert into group_members (group_id, user_id)
|
||||
select 1, id
|
||||
from json_table(
|
||||
:selected_items,
|
||||
'$[*]' columns (id int path '$')
|
||||
) as submitted_items;
|
||||
|
||||
select 'alert' as component, 'Group members successfully updated !' as title, 'success' as color;
|
||||
|
||||
select 'list' as component, 'Users in this group' as title;
|
||||
|
||||
select name as title, email as description
|
||||
from users
|
||||
join group_members on users.id = group_members.user_id
|
||||
where group_members.group_id = 1;
|
||||
|
||||
select 'button' as component;
|
||||
select 'Go back' as title, 'index.sql' as link;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- include the common menu
|
||||
select 'dynamic' as component, sqlpage.run_sql('shell.sql') as properties;
|
||||
|
||||
-- Call our custom component from ./sqlpage/templates/dual-list.handlebars
|
||||
select
|
||||
'dual-list' as component,
|
||||
'form_action.sql' as action;
|
||||
|
||||
-- This SQL query returns the list of users, with a boolean indicating if they are in the group
|
||||
select
|
||||
id,
|
||||
name as label,
|
||||
group_members.group_id is not null as selected
|
||||
from
|
||||
users
|
||||
left join group_members on users.id = group_members.user_id
|
||||
and group_members.group_id = 1;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,5 @@
|
||||
select
|
||||
'shell' as component,
|
||||
'Custom form component' as title,
|
||||
'index' as menu_item,
|
||||
'basic' as menu_item;
|
||||
@@ -0,0 +1,40 @@
|
||||
create table users (
|
||||
id int primary key auto_increment,
|
||||
name varchar(255) not null,
|
||||
email varchar(255) not null
|
||||
);
|
||||
|
||||
create table `groups` (
|
||||
id int primary key auto_increment,
|
||||
name varchar(255) not null
|
||||
);
|
||||
|
||||
create table group_members (
|
||||
group_id int not null,
|
||||
user_id int not null,
|
||||
primary key (group_id, user_id),
|
||||
foreign key (group_id) references `groups` (id),
|
||||
foreign key (user_id) references users (id)
|
||||
);
|
||||
|
||||
INSERT INTO users (id, name, email) VALUES
|
||||
(1, 'John Smith', 'john@email.com'),
|
||||
(2, 'Jane Doe', 'jane@email.com'),
|
||||
(3, 'Bob Wilson', 'bob@email.com'),
|
||||
(4, 'Mary Johnson', 'mary@email.com'),
|
||||
(5, 'James Brown', 'james@email.com'),
|
||||
(6, 'Sarah Davis', 'sarah@email.com'),
|
||||
(7, 'Michael Lee', 'michael@email.com'),
|
||||
(8, 'Lisa Anderson', 'lisa@email.com'),
|
||||
(9, 'David Miller', 'david@email.com'),
|
||||
(10, 'Emma Wilson', 'emma@email.com');
|
||||
|
||||
INSERT INTO `groups` (id, name) VALUES
|
||||
(1, 'Team Alpha');
|
||||
|
||||
INSERT INTO group_members (group_id, user_id) VALUES
|
||||
(1, 1),
|
||||
(1, 2),
|
||||
(1, 3),
|
||||
(1, 4),
|
||||
(1, 5);
|
||||
@@ -0,0 +1,126 @@
|
||||
{{!-- This is a form that will post data to the URL specified in the top-level 'action' property coming from the SQL query --}}
|
||||
<form method="POST" action="{{action}}">
|
||||
{{!-- Create a row with centered content and spacing between items --}}
|
||||
<div class="row justify-content-center align-items-center g-4">
|
||||
{{!-- Left List Box: 5 columns wide (out of the 12 made available by bootstrap) --}}
|
||||
<div class="col-5">
|
||||
{{!-- Card with no border and subtle shadow --}}
|
||||
<div class="card border-0 shadow-sm">
|
||||
{{!-- Card header with white background, no border, semibold font, and secondary text color --}}
|
||||
<div class="card-header bg-white border-bottom fw-semibold text-secondary py-3">
|
||||
Available Items
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
{{!-- Multiple-select dropdown list, 300px tall --}}
|
||||
<select class="form-select" id="leftList" multiple style="height: 300px">
|
||||
{{!-- Loop through each row of data returned by the second SQL query (row-level properties are available as variables) --}}
|
||||
{{#each_row}}
|
||||
{{!-- Create an option for each item, marking it selected if the 'selected' property is true --}}
|
||||
<option class="py-2 px-3 rounded-1 my-1" value="{{id}}" {{#if selected}}selected{{/if}}>{{label}}</option>
|
||||
{{/each_row}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Middle section with transfer buttons (auto-sized column) --}}
|
||||
<div class="col-auto d-flex flex-column gap-2">
|
||||
{{!-- Right arrow button (→) to move items to selected list --}}
|
||||
<button type="button" class="btn btn-outline-primary rounded-circle p-0 d-flex align-items-center justify-content-center"
|
||||
id="moveRight"
|
||||
title="Move to selected"
|
||||
style="width: 40px; height: 40px">
|
||||
{{!-- icon_img is a helper that renders an icon image from tabler.io/icons --}}
|
||||
{{~icon_img 'arrow-narrow-right' 20~}}
|
||||
</button>
|
||||
{{!-- Left arrow button (←) to remove items from selected list --}}
|
||||
<button type="button" class="btn btn-outline-primary rounded-circle p-0 d-flex align-items-center justify-content-center"
|
||||
id="moveLeft"
|
||||
title="Remove from selected"
|
||||
style="width: 40px; height: 40px">
|
||||
{{~icon_img 'arrow-narrow-left' 20~}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{!-- Right List Box (5 columns wide) --}}
|
||||
<div class="col-5">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom fw-semibold text-secondary py-3">
|
||||
Selected Items
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
{{!-- Multiple-select dropdown that will contain selected items. The name attribute makes it submit as an array --}}
|
||||
<select class="form-select" id="rightList" name="selected_items[]" multiple style="height: 300px"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{!-- Submit Button Section (full width) --}}
|
||||
<div class="col-12 text-center mt-4">
|
||||
<input type="submit" class="btn btn-primary px-4 py-2 fw-semibold shadow-sm"
|
||||
id="submitBtn"
|
||||
disabled
|
||||
value="Submit Selection">
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{!-- JavaScript code with CSP (Content Security Policy) nonce for security --}}
|
||||
<script nonce="{{@csp_nonce}}">
|
||||
// Get references to both list elements
|
||||
// If we wanted to be able to include this component multiple times on the same page,
|
||||
// we would need to generate IDs like "rightList1", "leftList1", etc. using a template string.like "rightList{{@component_index}}"
|
||||
const rightList = document.getElementById('rightList');
|
||||
const leftList = document.getElementById('leftList');
|
||||
|
||||
/**
|
||||
* Moves selected items from one list to another while maintaining alphabetical order
|
||||
* @param {HTMLSelectElement} fromList - The list to take items from
|
||||
* @param {HTMLSelectElement} toList - The list to add items to
|
||||
*/
|
||||
function transferItems(fromList, toList) {
|
||||
// Deselect all items in the destination list
|
||||
for (const option of toList.options) option.selected = false;
|
||||
|
||||
// Combine existing options with newly selected ones
|
||||
const newOptions = [...toList.options, ...fromList.selectedOptions];
|
||||
|
||||
// Sort the combined options alphabetically
|
||||
newOptions.sort((a, b) => a.text.localeCompare(b.text));
|
||||
|
||||
// Add all options to the destination list
|
||||
// Since an element can only exist once in the page,
|
||||
// this will automatically remove the options from the source list
|
||||
toList.append(...newOptions);
|
||||
|
||||
// Focus the destination list and update submit button state
|
||||
toList.focus();
|
||||
updateSubmitButton();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable/disable submit button based on whether there are items in the right list
|
||||
*/
|
||||
function updateSubmitButton() {
|
||||
submitBtn.disabled = rightList.options.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all items in right list are selected before form submission
|
||||
* This is necessary because unselected options aren't included in form data
|
||||
*/
|
||||
function handleSubmit() {
|
||||
for (const option of rightList.options) option.selected = true;
|
||||
}
|
||||
|
||||
// Set initial state of submit button
|
||||
updateSubmitButton();
|
||||
|
||||
// Move any pre-selected items to the right list when page loads
|
||||
transferItems(leftList, rightList);
|
||||
|
||||
// Set up click handlers for the transfer buttons and form submission
|
||||
document.getElementById('moveRight').addEventListener('click', transferItems.bind(null, leftList, rightList));
|
||||
document.getElementById('moveLeft').addEventListener('click', transferItems.bind(null, rightList, leftList));
|
||||
document.querySelector('form').addEventListener('submit', handleSubmit);
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
GET http://localhost:8080/index.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Available Items"
|
||||
body contains "Selected Items"
|
||||
body contains "John Smith"
|
||||
body contains "Jane Doe"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/form_action.sql
|
||||
[FormParams]
|
||||
selected_items[]: 6
|
||||
selected_items[]: 8
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Group members successfully updated"
|
||||
body contains "Users in this group"
|
||||
body contains "Sarah Davis"
|
||||
body contains "sarah@email.com"
|
||||
body contains "Lisa Anderson"
|
||||
body contains "lisa@email.com"
|
||||
body not contains "John Smith"
|
||||
body not contains "An error occurred"
|
||||
Reference in New Issue
Block a user