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
@@ -0,0 +1,292 @@
|
||||
This demo/template defines a basic CRUD application with authentication designed for use with SQLite. The primary goal of this demo is to explore some of the SQLPage features and figure out how to implement various aspects of a data-centric application. The template contains both public pages ([**SQLite Introspection**](www/intro.sql)) and pages requiring authentication. A user management GUI is not available (database migrations, included in the project, create a default login admin/admin).
|
||||
|
||||
The website root is set to _./www_, and the database is in the _./db/_ directory. The database contains one data table, "currencies", and its content is listed on a login-protected [**page**](www/currencies_list.sql). After successful authentication, you can see the list of records and access the form for adding/editing/deleting records.
|
||||
|
||||
## Authentication process
|
||||
|
||||
Three files (login.sql, logout.sql, and create_session.sql) implement authentication mostly following the code provided in other examples. The login.sql defines the actual login form, and the two other files do not have any associated GUI but perform appropriate processing and redirect to designated targets. Given a protected page, the general authentication flow is as follows.
|
||||
|
||||
1. The user attempts to open a protected page (e.g., currencies_list.sql)
|
||||
2. Session checking code snippet at the top of the protected page checks if a valid session token (cookie) is set. In this example, the SET statement sets a local variable, `$_username`, for later use:
|
||||
```sql
|
||||
-- Checks if a valid session token cookie is available
|
||||
set _username = (
|
||||
SELECT username
|
||||
FROM sessions
|
||||
WHERE sqlpage.cookie('session_token') = id
|
||||
AND created_at > datetime('now', '-1 day')
|
||||
);
|
||||
```
|
||||
3. Redirect to login page (login.sql) if no session is available (`$_username IS NULL`) and the starting page requires authentication (by setting `set _session_required = 1;` before executing the session checking code; see, e.g., the top of currencies_item_form.sql and currencies_list.sql):
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
sqlpage.link('/login.sql', json_object('path', $_curpath)) AS link
|
||||
WHERE $_username IS NULL AND $_session_required;
|
||||
```
|
||||
4. The login page renders the login form, accepts the user credentials, and redirects to create_session.sql, passing the login credentials as POST variables.
|
||||
5. create_session.sql checks credentials. If this check fails, it redirects back to the login form. If the check succeeds, it generates a session token and performs the final redirect.
|
||||
|
||||
## Header module
|
||||
|
||||
### Controlling execution of parts in a loaded script
|
||||
|
||||
Because the same code is used for session token check for all protected pages, it makes sense to place it in a separate module (header_shell_session.sql) and execute it via run_sql() at the top of protected files:
|
||||
|
||||
```sql
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 1;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
```
|
||||
|
||||
The second line above sets the local variable $\_session_required, which indicates whether authentication is required for a particular page. This variable, like the GET/POST variables, is then accessible to the loaded "header" module header_shell_session.sql. This way, if other common code is placed in the header module, it can be executed by a non-protected page while skipping the authentication part (by setting $\_session_required = 0, which prevents redirect to the login form even if no valid session token is available).
|
||||
|
||||
Another "filter" variable (`$_shell_enabled`) controls the execution of another section in the header module, as discussed below.
|
||||
|
||||
### Tracking the calling page
|
||||
|
||||
The first line above sets another useful variable $\_curpath, which makes it possible to redirect back to the starting page after the authentication process is completed, rather than redirecting to the front page. The loaded header module has access to this variable as well, and if a login redirect is required, this variable is passed alone as a GET URL parameter (as a part of the "link" property):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
'/login.sql?path=' || $_curpath AS link
|
||||
WHERE $_username IS NULL AND $_session_required;
|
||||
```
|
||||
|
||||
The login form passes it further in a similar fashion to the create_session.sql script (as part of the "action" property):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'form' AS component,
|
||||
'Login' AS title,
|
||||
'create_session.sql' || ifnull('?path=' || $path, '') AS action;
|
||||
```
|
||||
If authentication fails, create_session.sql redirects back to login.sql and makes sure to pass the $path value alone (as a part of the "link" property):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'authentication' AS component,
|
||||
'login.sql?' || ifnull('path=' || $path, '') || '&error=1' AS link,
|
||||
:password AS password,
|
||||
(SELECT password_hash
|
||||
FROM "accounts"
|
||||
WHERE username = :username) AS password_hash;
|
||||
```
|
||||
|
||||
If authentication succeeds, create_session.sql redirects back to starting page using the $path value as the final redirect target:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
ifnull($path, '/') AS link;
|
||||
```
|
||||
|
||||
### Adding User/Login/Logout buttons to the page menu
|
||||
|
||||
It is customary to show the "User Profile" button in the top right corner when the user is authenticated. Also, it is customary to show the "Logout" button next to the "User Profile" button or the "Login" button when no active session is available. The associated code is common to all pages, and it makes sense to place it in the same header module.
|
||||
|
||||
The "shell" component is responsible for constructing the top menu, but the standard component does not support menu buttons. The simplest solution to this "limitation" is to modify the standard shell.handlebars template found in the "sqlpage/templates" directory of the SQLPage source code repository and place it inside the project "sqlpage/templates" directory.
|
||||
|
||||
To extend the "shell" component with button items in the menu, I have added a hybrid section of code mostly constructed from template code defining menu items and the "button" component. In the present implementation, menu buttons are defined as a JSON array value to the "menu_buttons" property. Each array member is a JSON object defining a single button and may include "shape", "color", "size", "outline", "link", "tooltip", and "title" properties (see description of these properties in the official "button" component docs.
|
||||
|
||||
Note how the `$_curpath` variable, which is set in core page modules (such as currencies_list.sql) is used to define links for the Login/Logout buttons. These links are irrelevant for protected pages, but for non-protected pages, such as intro.sql, these links make sure that the user remains on the same page after he/she presses on Logout/Login buttons (and completes authentication in the latter case).
|
||||
|
||||
The `$_username` variable set during the authentication process is then used to decide which buttons (Login or User/Logout) should be shown.
|
||||
|
||||
The `$_shell_enabled` variable controls the execution of the custom shell component. This feature is necessary because the header module is also loaded by the currencies_item_dml.sql module, which should only be accessible to authenticated users. However, the currencies_item_dml.sql module is a no-GUI module, which performs database operations and uses redirects after the requested operations are completed. At the same time, if the loaded header module executes the custom shell component, generating GUI buttons, the redirection mechanism in currencies_item_dml.sql will fail.
|
||||
|
||||
### Required variable guards
|
||||
|
||||
The header modules expects that the calling module sets several variables. The SET statement makes it possible to check if the variables are set appropriately in one place at the beginning of the module, rather then placing guards every time theses variables are used. Hence, the top section of the header file includes
|
||||
|
||||
```sql
|
||||
set _curpath = ifnull($_curpath, '/');
|
||||
set _session_required = ifnull($_session_required, 1);
|
||||
set _shell_enabled = ifnull($_shell_enabled, 1);
|
||||
```
|
||||
In this case, if any required variable is not set, a suitable default value is defined, so that the following code would not have to check for NULL values. Alternatively, a redirect to an error page may be used, to inform the programmer about the potential issue.
|
||||
|
||||
## Footer module - debug information
|
||||
|
||||
POST/GET/SET variables may provide helpful information for debugging purposes. In earlier [post](https://reddit.com/r/SQLpage/comments/1dh1siw/structuring_code_showing_debug_info/), I described the code I use to output variables in a convenient way. Briefly, I use `sqlpage.variables('GET')` and `sqlpage.variables('POST')` to get all variables, and I distinguish between the GET variables and local SET variables by prefixing SET variable names with an underscore. Initially, I copy-pasted the code snippets at the bottom of pages, but later I moved it to a separate file, footer_debug_post-get-set.sql, which I load via
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties
|
||||
WHERE $DEBUG OR $error IS NOT NULL;
|
||||
```
|
||||
## Structuring code modules
|
||||
|
||||
The "currencies" table is handled by three modules:
|
||||
|
||||
- "table" view - __currencies_list.sql__
|
||||
Displays the entire table using the powerful "table" component. One way to extend this module is, possibly, to hide certain less important columns, especially for wide tables.
|
||||
- "detail" view - __currencies_item_form.sql__
|
||||
This is the "detail" view. It shows all fields for a single record. In this case, it is an "editable" form, though the fields maybe made conditionally read-only. Another possible option for a read-only detail view is to use the "datagrid" component.
|
||||
- database DML processor - __currencies_item_dml.sql__
|
||||
This is a no-GUI module, which only processes database modification operations using data submitted to the currencies_item_form.sql form. Presently, all DML statements (INSERT/UPDATE/DELETE) are processed by this module. If necessary, this module maybe split into more specialized modules.
|
||||
|
||||
Let us briefly go over the code block in these modules.
|
||||
|
||||
### Debug information (bottom section)
|
||||
|
||||
All three module load the footer module discussed above that produces a conditional output of GET/POST/SET variables.
|
||||
|
||||
### Authentication (top section)
|
||||
|
||||
All three modules provide access to the database and are treated as protected: they are only accessible to authenticated users. Hence, they start with (mostly) the same code block:
|
||||
|
||||
```sql
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 1;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
```
|
||||
|
||||
This code discussed above sets the current path variable (necessary for correct redirects), authentication flag before loading the header module that takes care of authentication and common settings, such as top menu buttons.
|
||||
|
||||
The "no-GUI" currencies_item_dml.sql module does not set `$_curpath`, since it cannot be a start/end point in a redirect chain, but it sets the `$_shell_enabled` flag to suppress top menu buttons generation, as discussed earlier.
|
||||
|
||||
### Common variables
|
||||
|
||||
The second section may generally be used to set additional common variables, such as the name of the "table" view inside the "detail" view and the other way around (to switch between the two).
|
||||
|
||||
The "detail" view also uses the "&path" GET URL parameter, if provided (e.g., by the "table" view). This way, if a record is modified/deleted starting from, e.g., the "table" view, the same view is set as the final redirect target after the DML operation is completed.
|
||||
|
||||
### Table view
|
||||
|
||||
The rest of the table view module is fairly basic. It defines two alerts for displaying confirmation and error messages, a "new record" button, and the table itself. The last "actions" column is added to the table, designated as markdown, and includes shortcuts to edit/delete the corresponding record.
|
||||
|
||||

|
||||
|
||||
### Detail view
|
||||
|
||||
The detail view module is more elaborate. If "&id" GET URL parameter is provided, the form shows the corresponding record. Otherwise, the ID field is rendered as a dropdown list populated from the database, but is set to NULL. The remaining fields are either blank or contain dummy values.
|
||||
|
||||
The first step (after the previously discussed common sections), therefore, is to filter invalid id values.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath AS link
|
||||
WHERE $id = '' OR CAST($id AS INT) = 0;
|
||||
|
||||
set error_msg = sqlpage.url_encode('Bad {id = ' || $id || '} provided');
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath || '?error=' || $error_msg AS link
|
||||
WHERE $id NOT IN (SELECT currencies.id FROM currencies);
|
||||
```
|
||||
|
||||
The blank string and zero are considered the equivalents of NULL, so redirect to itself is activated, removing the id parameter. If no id is provided or id is set to an integer value, the first check does not trigger. The second check above triggers when there is no record with provided id. This check resets id and displays an error message.
|
||||
|
||||
Another accepted GET URL parameter is $values, which may be set to a JSON representation of the record. This parameter is returned from the currencies_item_dml.sql script if the database operation fails. Then the detail view will display an error message, but the form will remain populated with the user-submitted data. If $values is set, it takes precedence. This check throws an error if $values is set, but does not represent a valid JSON.
|
||||
|
||||
```sql
|
||||
set _err_msg =
|
||||
sqlpage.url_encode('Values is set to bad JSON: __ ') || $values || ' __';
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath || '?error=' || $_err_msg AS link
|
||||
WHERE NOT json_valid($values);
|
||||
```
|
||||
The detail view maybe called with zero, one, or two (\$id/\$values) parameters. Invalid values are filtered out at this point, so the next step is to check provided parameters and determine the dataset that should go into the form.
|
||||
|
||||
```sql
|
||||
set _values = (
|
||||
WITH
|
||||
fields AS (
|
||||
SELECT id, name, to_rub
|
||||
FROM currencies
|
||||
WHERE id = CAST($id AS INT) AND $values IS NULL
|
||||
UNION ALL
|
||||
SELECT NULL, '@', 1
|
||||
WHERE $id IS NULL AND $values IS NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
$values ->> '$.id' AS id,
|
||||
$values ->> '$.name' AS name,
|
||||
$values ->> '$.to_rub' AS to_rub
|
||||
WHERE json_valid($values)
|
||||
)
|
||||
SELECT
|
||||
json_object(
|
||||
'id', CAST(fields.id AS INT),
|
||||
'name', fields.name,
|
||||
'to_rub', CAST(CAST(fields.to_rub AS TEXT) AS NUMERIC)
|
||||
)
|
||||
FROM fields
|
||||
);
|
||||
```
|
||||
|
||||
Each of the three united SELECTs in the "fields" CTE returns a single row and only one of them is selected for any given combination of \$id/\$values using the WHERE clauses. This query returns the "final" set of fields as a JSON object.
|
||||
|
||||

|
||||
|
||||
Now that the input parameters are validated and the "final" dataset is determined, it is the time to define the form GUI elements. First, I define the button to switch to the table view. Note that the same form is used to confirm record deletion, and when this happens, the "Browse" button is not shown.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'button' AS component,
|
||||
'pill' AS shape,
|
||||
'lg' AS size,
|
||||
'end' AS justify;
|
||||
SELECT
|
||||
'BROWSE' AS title,
|
||||
'browse_rec' AS id,
|
||||
'corner-down-left' AS icon,
|
||||
'corner-down-left' AS icon_after,
|
||||
'green' AS outline,
|
||||
$_table_list AS link,
|
||||
'Browse full table' AS tooltip
|
||||
WHERE NOT ifnull($action = 'DELETE', FALSE);
|
||||
```
|
||||
|
||||
The following section defines the main form with record fields. First the $\_valid_ids variable is constructed as the source for the drop-down id field. The code also adds the NULL value used for defining a new record. Note that, when this form is opened from the table view via the "New Record" button, the $action variable is set to "INSERT" and the id field is set to the empty array in the first assignment via the alternative UINION and to the single NULL in the second assignment. The two queries can also be combined relatively straightforwardly using CTEs.
|
||||
|
||||
```sql
|
||||
set _valid_ids = (
|
||||
SELECT json_group_array(
|
||||
json_object('label', CAST(id AS TEXT), 'value', id) ORDER BY id
|
||||
)
|
||||
FROM currencies
|
||||
WHERE ifnull($action, '') <> 'INSERT'
|
||||
UNION ALL
|
||||
SELECT '[]'
|
||||
WHERE $action = 'INSERT'
|
||||
);
|
||||
set _valid_ids = (
|
||||
json_insert($_valid_ids, '$[#]',
|
||||
json_object('label', 'NULL', 'value', json('null'))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
The next part defines form fields via the "dynamic" component (for some reason I am having issues with POST variables when the form is defined directly via the "form" component. Note how the $values variable prepared in previous blocks is used to populate the form. Without the SET statement, everything would need to be incorporated in a single query (which is feasible thanks to CTEs, but would still be significantly more difficult to develop and maintain).
|
||||
|
||||
Also note that this single form definition actually combines two forms (the second being the record delete confirmation form). If the $action variable is set to "DELETE" (after the delete operation is initiated from either the table or detail view), buttons are adjusted appropriately and all fields are set to read-only. Whether this is a good design is a separate question. Perhaps, defining two separate forms is a better approach.
|
||||
|
||||

|
||||
|
||||
|
||||
After the main form fields goes the delete confirmation alert, displayed after the delete operation is completed.
|
||||
|
||||
The last big section defines the main form buttons, which are adjusted based on the type of operation (similarly to the form fields above).
|
||||
|
||||
The final section includes a general confirmation alert (used after INSERT/UPDATE operations) and an error alert.
|
||||
|
||||
### Coding style conventions
|
||||
|
||||
Consistent code style is important for code readability. Because SQLPage module maybe a mix of SQL code and sizeable text fragments, which may contain plain text, Markdown, JSON, HTML, etc., it might be difficult to follow a fixed set of rules. In fact, dynamically generated webpages regardless of specific technologies used tend to get messy. At the very least I strive to
|
||||
|
||||
- keep all SQL keywords always in the UPPER case,
|
||||
- have reasonably sensible code alignment (though some alignment approaches may not be generally advisable)
|
||||
- keep large static text pieces in separate appropriate dedicated files and load them via `sqlpage.read_file_as_text()` (e.g., the text of this file comes from Readme.md, where it can be properly edited by any Markdown editor and version-controlled; similarly, static JSON should go in \*.json files or in a dedicated database table with a designated JSON column).
|
||||
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./www:/var/www
|
||||
- ./sqlpage:/etc/sqlpage
|
||||
environment:
|
||||
DATABASE_URL: sqlite:///tmp/components.db?mode=rwc
|
||||
@@ -0,0 +1,39 @@
|
||||
DROP TABLE IF EXISTS _sqlx_migrations;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS _sqlx_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT COLLATE NOCASE NOT NULL,
|
||||
installed_on TEXT COLLATE NOCASE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
success INTEGER NOT NULL,
|
||||
checksum BLOB NOT NULL,
|
||||
execution_time INTEGER NOT NULL
|
||||
);
|
||||
|
||||
|
||||
-- The path field should be relative to the www root. Do not
|
||||
-- include absolute paths pointing to files outside the www root.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sqlpage_files (
|
||||
path TEXT COLLATE NOCASE NOT NULL UNIQUE
|
||||
GENERATED ALWAYS AS (
|
||||
iif(prefix IS NOT NULL AND length(prefix) > 0, prefix || '/', '') ||
|
||||
name
|
||||
),
|
||||
contents BLOB,
|
||||
last_modified TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
prefix TEXT COLLATE NOCASE NOT NULL DEFAULT '',
|
||||
name TEXT COLLATE NOCASE NOT NULL,
|
||||
tag TEXT COLLATE NOCASE,
|
||||
src_url TEXT COLLATE NOCASE,
|
||||
PRIMARY KEY(prefix, name)
|
||||
);
|
||||
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS sqlpage_files_update
|
||||
AFTER UPDATE OF path, contents ON sqlpage_files
|
||||
WHEN old.last_modified = new.last_modified
|
||||
BEGIN
|
||||
UPDATE sqlpage_files
|
||||
SET last_modified = CURRENT_TIMESTAMP
|
||||
WHERE last_modified = new.last_modified;
|
||||
END;
|
||||
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||
"username" TEXT COLLATE NOCASE PRIMARY KEY,
|
||||
"password_hash" TEXT COLLATE BINARY NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"id" TEXT COLLATE NOCASE PRIMARY KEY,
|
||||
"username" TEXT COLLATE NOCASE NOT NULL
|
||||
REFERENCES "accounts"("username"),
|
||||
"created_at" TEXT COLLATE NOCASE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- Creates an initial user with the username `admin` and the password `admin` (hashed using sqlpage.hash_password('admin'))
|
||||
|
||||
INSERT OR IGNORE INTO "accounts"("username", "password_hash") VALUES
|
||||
('admin', '$argon2id$v=19$m=19456,t=2,p=1$4lu3hSvaqXK0dMCPZLOIPg$PUFJSB6L3J5eZ33z9WX7y0nOH6KawV2FdW0abMuPE7o');
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS "currencies" (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT COLLATE NOCASE NOT NULL UNIQUE,
|
||||
"to_rub" REAL NOT NULL
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO "currencies"("name", "to_rub") VALUES
|
||||
('RUR', 1),
|
||||
('USD', 90),
|
||||
('CNY', 12.34);
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"listen_on": "0.0.0.0:8080",
|
||||
"database_url": "sqlite://./db/Components.sqlite?mode=rwc",
|
||||
"allow_exec": false,
|
||||
"web_root": "./www"
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{language}}" style="font-size: {{default font_size 18}}px" {{#if class}}class="{{class}}"{{/if}}>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>{{default title "SQLPage"}}</title>
|
||||
{{#if favicon}}
|
||||
<link rel="icon" href="{{favicon}}">
|
||||
{{/if}}
|
||||
{{#if manifest}}
|
||||
<link rel="manifest" href="{{manifest}}">
|
||||
{{/if}}
|
||||
<link rel="stylesheet" href="{{static_path 'sqlpage.css'}}">
|
||||
{{#each (to_array css)}}
|
||||
{{#if this}}
|
||||
<link rel="stylesheet" href="{{this}}">
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
{{#if font}}
|
||||
{{#if (startsWith font "/")}}
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'LocalFont';
|
||||
src: url('{{font}}') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
:root {
|
||||
--tblr-font-sans-serif: 'LocalFont', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
{{else}}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family={{font}}&display=fallback">
|
||||
<style>:root { --tblr-font-sans-serif: '{{font}}', Arial, sans-serif;}</style>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
<script src="{{static_path 'sqlpage.js'}}" defer></script>
|
||||
{{#each (to_array javascript)}}
|
||||
{{#if this}}
|
||||
<script src="{{this}}" defer></script>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<meta name="description" content="{{description}}"/>
|
||||
{{#if norobot}}
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
{{/if}}
|
||||
|
||||
{{#if refresh}}
|
||||
<meta http-equiv="refresh" content="{{refresh}}">
|
||||
{{/if}}
|
||||
{{#if rss}}
|
||||
<link rel="alternate" type="application/rss+xml" title="{{title}}" href="{{rss}}">
|
||||
{{/if}}
|
||||
<meta name="generator" content="SQLPage"/>
|
||||
|
||||
{{#if social_image}}
|
||||
<meta property="og:image" content="{{social_image}}"/>
|
||||
{{/if}}
|
||||
</head>
|
||||
|
||||
<body class="layout-{{default layout 'boxed'}}" {{#if theme}}data-bs-theme="{{theme}}"{{/if}}>
|
||||
<div class="page">
|
||||
{{#if title}}
|
||||
<header id="sqlpage_header">
|
||||
<nav class="navbar navbar-expand-md navbar-light{{#if fixed_top_menu}} fixed-top{{/if}}">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand flex-grow-1 overflow-hidden" href="{{#if link}}{{link}}{{else}}/{{/if}}">
|
||||
{{#if image}}
|
||||
<img src="{{image}}" alt="{{title}}" width="32" height="32"
|
||||
class="navbar-brand-image">
|
||||
{{/if}}
|
||||
{{#if icon}}
|
||||
{{~icon_img icon~}}
|
||||
{{/if}}
|
||||
<h1 class="mb-0 w-0 fs-2">{{title}}</h1>
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
|
||||
data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbar-menu">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{{#each (to_array menu_item)}}
|
||||
{{#if (or (eq (typeof this) 'object') (and (eq (typeof this) 'string') (starts_with this '{')))}}
|
||||
{{#with (parse_json this)}}
|
||||
{{#if (or (gt (len this.title) 0) (gt (len this.icon) 0) (gt (len this.image) 0))}}
|
||||
<li class="nav-item{{#if this.submenu}} dropdown{{/if}}{{#if this.button}} px-2{{/if}}">
|
||||
<a class="
|
||||
{{~#if this.button~}}
|
||||
btn text-wrap me-1
|
||||
{{~#if this.color}} btn-{{this.color}}{{/if}}
|
||||
{{~#if this.size}} btn-{{this.size}}{{/if}}
|
||||
{{~#if this.outline}} btn-outline-{{this.outline}}{{/if}}
|
||||
{{~#if this.shape}} btn-{{this.shape}}{{/if}}
|
||||
{{~#if this.narrow}} btn-icon{{/if}}
|
||||
{{~else~}}
|
||||
nav-link
|
||||
{{~/if}}
|
||||
{{~#if this.submenu}} dropdown-toggle{{/if}}"
|
||||
href="{{#if this.link}}{{this.link}}{{else}}#{{/if}}"
|
||||
{{#if this.submenu}}data-bs-toggle="dropdown" data-bs-auto-close="outside"{{/if}}
|
||||
role="button"
|
||||
>
|
||||
{{~#if this.image~}}
|
||||
<span {{~#if this.title}} class="me-1"{{/if}}>
|
||||
{{~#if (eq this.size 'sm')}}
|
||||
<img width=16 height=16 src="{{this.image}}">
|
||||
{{~else~}}
|
||||
<img width=24 height=24 src="{{this.image}}">
|
||||
{{~/if~}}
|
||||
</span>
|
||||
{{~/if~}}
|
||||
{{#if this.icon}}
|
||||
{{#if this.title}}<span class="me-1">{{/if}}
|
||||
{{~icon_img this.icon~}}
|
||||
{{#if this.title}}</span>{{/if}}
|
||||
{{/if}}
|
||||
{{this.title}}
|
||||
</a>
|
||||
{{#if this.submenu}}
|
||||
<div class="dropdown-menu dropdown-menu-end" data-bs-popper="static" style="min-width: inherit;">
|
||||
{{#each this.submenu}}
|
||||
{{#if (or (gt (len this.title) 0) (gt (len this.icon) 0) (gt (len this.image) 0))}}
|
||||
{{~#if this.button~}}<div class="mx-2" style="text-align: center;">{{/if}}
|
||||
<a class="
|
||||
{{~#if this.button~}}
|
||||
btn text-wrap my-1
|
||||
{{~#if this.color}} btn-{{this.color}}{{/if}}
|
||||
{{~#if this.size}} btn-{{this.size}}{{/if}}
|
||||
{{~#if this.outline}} btn-outline-{{this.outline}}{{/if}}
|
||||
{{~#if this.shape}} btn-{{this.shape}}{{/if}}
|
||||
{{~#if this.narrow}} btn-icon{{/if}}
|
||||
{{~else~}}
|
||||
dropdown-item
|
||||
{{~/if}}" style=" min-width: inherit;"
|
||||
{{~#if tooltip}} data-bs-toggle="tooltip" data-bs-placement="top" title="{{tooltip}}"{{/if}}
|
||||
href="{{this.link}}"
|
||||
>
|
||||
{{~#if this.image~}}
|
||||
<span {{~#if this.title}} class="me-1"{{/if}}>
|
||||
{{~#if (eq this.size 'sm')}}
|
||||
<img width=16 height=16 src="{{this.image}}">
|
||||
{{~else~}}
|
||||
<img width=24 height=24 src="{{this.image}}">
|
||||
{{~/if~}}
|
||||
</span>
|
||||
{{~/if~}}
|
||||
{{#if this.icon}}
|
||||
{{#if this.title}}<span class="me-1">{{/if}}
|
||||
{{~icon_img this.icon~}}
|
||||
{{#if this.title}}</span>{{/if}}
|
||||
{{/if}}
|
||||
{{this.title}}
|
||||
</a>
|
||||
{{~#if this.button~}}</div>{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/with}}
|
||||
{{~else}}
|
||||
{{~#if (gt (len this) 0)~}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-capitalize" href="{{this}}.sql">{{this}}</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#if search_target}}
|
||||
<form class="d-flex" role="search" action="{{search_target}}">
|
||||
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search" name="search" value="{{search_value}}">
|
||||
<button class="btn btn-outline-success" type="submit">Search</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
{{/if}}
|
||||
<main class="page-wrapper container-xl pt-3 px-md-5 px-sm-3 {{#if fixed_top_menu}} mt-5{{/if}}" id="sqlpage_main_wrapper">
|
||||
{{~#each_row~}}{{~/each_row~}}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="w-100 text-center fs-6 my-2 text-secondary" id="sqlpage_footer">
|
||||
{{#if footer}}
|
||||
{{{markdown footer}}}
|
||||
{{else}}
|
||||
<!-- You can change this footer using the 'footer' parameter of the 'shell' component -->
|
||||
Built with <a class="text-reset" href="https://sql-page.com" title="SQLPage v{{buildinfo 'CARGO_PKG_VERSION'}}">SQLPage</a>
|
||||
{{/if}}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,135 @@
|
||||
# Public homepage is reachable without a session.
|
||||
GET http://localhost:8080
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/html"
|
||||
body contains "Demo/Template CRUD with Authentication"
|
||||
body not contains "An error occurred"
|
||||
|
||||
# The currencies CRUD page is protected.
|
||||
GET http://localhost:8080/currencies_list.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "/login.sql?path=/currencies_list.sql"
|
||||
|
||||
GET http://localhost:8080/login.sql?path=/currencies_list.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Login"
|
||||
body contains "Username"
|
||||
body contains "Password"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/create_session.sql?path=/currencies_list.sql
|
||||
[FormParams]
|
||||
username: admin
|
||||
password: wrong
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "login.sql"
|
||||
header "Location" contains "error=1"
|
||||
|
||||
GET http://localhost:8080/currencies_list.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "/login.sql?path=/currencies_list.sql"
|
||||
|
||||
# Demo credentials from the example migration: admin/admin.
|
||||
POST http://localhost:8080/create_session.sql?path=/currencies_list.sql
|
||||
[FormParams]
|
||||
username: admin
|
||||
password: admin
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Set-Cookie" contains "session_token="
|
||||
header "Location" == "/currencies_list.sql"
|
||||
|
||||
# Hurl's cookie store reuses the session_token set above.
|
||||
GET http://localhost:8080/currencies_list.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Currencies"
|
||||
body contains "New Record"
|
||||
body contains "RUR"
|
||||
body contains "USD"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/currencies_item_form.sql?id=1
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Currency"
|
||||
body contains "Exchange Rate to RUR"
|
||||
body contains "RUR"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/currencies_item_form.sql?action=INSERT
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Currency"
|
||||
body contains "Exchange Rate to RUR"
|
||||
body contains "BROWSE"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/currencies_item_dml.sql?path=currencies_list.sql&action=UPDATE
|
||||
[FormParams]
|
||||
id:
|
||||
name: HURLTEST
|
||||
to_rub: 7.89
|
||||
HTTP 302
|
||||
[Captures]
|
||||
currency_id: header "Location" regex "id=(\\d+)"
|
||||
[Asserts]
|
||||
header "Location" contains "currencies_list.sql"
|
||||
header "Location" contains "info=INSERT"
|
||||
|
||||
GET http://localhost:8080/currencies_list.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "HURLTEST"
|
||||
body contains "7.89"
|
||||
body htmlUnescape contains "currencies_item_form.sql?&path=/currencies_list.sql&id={{currency_id}}"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/currencies_item_dml.sql?path=currencies_list.sql&action=UPDATE
|
||||
[FormParams]
|
||||
id: {{currency_id}}
|
||||
name: HURLEDITED
|
||||
to_rub: 8.91
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "currencies_list.sql"
|
||||
header "Location" contains "id={{currency_id}}"
|
||||
header "Location" contains "info=UPDATE"
|
||||
|
||||
GET http://localhost:8080/currencies_item_form.sql?id={{currency_id}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "HURLEDITED"
|
||||
body contains "8.91"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/currencies_item_dml.sql?path=currencies_list.sql&action=DELETE
|
||||
[FormParams]
|
||||
id: {{currency_id}}
|
||||
name: HURLEDITED
|
||||
to_rub: 8.91
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "currencies_list.sql"
|
||||
header "Location" contains "info=DELETE"
|
||||
|
||||
GET http://localhost:8080/currencies_list.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body not contains "HURLEDITED"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/logout.sql?path=currencies_list.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "currencies_list.sql"
|
||||
|
||||
GET http://localhost:8080/currencies_list.sql
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "/login.sql?path=/currencies_list.sql"
|
||||
@@ -0,0 +1,292 @@
|
||||
This demo/template defines a basic CRUD application with authentication designed for use with SQLite. The primary goal of this demo is to explore some of the SQLPage features and figure out how to implement various aspects of a data-centric application. The template contains both public pages ([**SQLite Introspection**](intro.sql)) and pages requiring authentication. A user management GUI is not available (database migrations, included in the project, create a default login admin/admin).
|
||||
|
||||
The website root is set to _./www_, and the database is in the _./db/_ directory. The database contains one data table, "currencies", and its content is listed on a login-protected [**page**](currencies_list.sql). After successful authentication, you can see the list of records and access the form for adding/editing/deleting records.
|
||||
|
||||
## Authentication process
|
||||
|
||||
Three files (login.sql, logout.sql, and create_session.sql) implement authentication mostly following the code provided in other examples. The login.sql defines the actual login form, and the two other files do not have any associated GUI but perform appropriate processing and redirect to designated targets. Given a protected page, the general authentication flow is as follows.
|
||||
|
||||
1. The user attempts to open a protected page (e.g., currencies_list.sql)
|
||||
2. Session checking code snippet at the top of the protected page checks if a valid session token (cookie) is set. In this example, the SET statement sets a local variable, `$_username`, for later use:
|
||||
```sql
|
||||
-- Checks if a valid session token cookie is available
|
||||
set _username = (
|
||||
SELECT username
|
||||
FROM sessions
|
||||
WHERE sqlpage.cookie('session_token') = id
|
||||
AND created_at > datetime('now', '-1 day')
|
||||
);
|
||||
```
|
||||
3. Redirect to login page (login.sql) if no session is available (`$_username IS NULL`) and the starting page requires authentication (by setting `set _session_required = 1;` before executing the session checking code; see, e.g., the top of currencies_item_form.sql and currencies_list.sql):
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
'/login.sql?path=' || $_curpath AS link
|
||||
WHERE $_username IS NULL AND $_session_required;
|
||||
```
|
||||
4. The login page renders the login form, accepts the user credentials, and redirects to create_session.sql, passing the login credentials as POST variables.
|
||||
5. create_session.sql checks credentials. If this check fails, it redirects back to the login form. If the check succeeds, it generates a session token and performs the final redirect.
|
||||
|
||||
## Header module
|
||||
|
||||
### Controlling execution of parts in a loaded script
|
||||
|
||||
Because the same code is used for session token check for all protected pages, it makes sense to place it in a separate module (header_shell_session.sql) and execute it via run_sql() at the top of protected files:
|
||||
|
||||
```sql
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 1;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
```
|
||||
|
||||
The second line above sets the local variable $\_session_required, which indicates whether authentication is required for a particular page. This variable, like the GET/POST variables, is then accessible to the loaded "header" module header_shell_session.sql. This way, if other common code is placed in the header module, it can be executed by a non-protected page while skipping the authentication part (by setting $\_session_required = 0, which prevents redirect to the login form even if no valid session token is available).
|
||||
|
||||
Another "filter" variable (`$_shell_enabled`) controls the execution of another section in the header module, as discussed below.
|
||||
|
||||
### Tracking the calling page
|
||||
|
||||
The first line above sets another useful variable $\_curpath, which makes it possible to redirect back to the starting page after the authentication process is completed, rather than redirecting to the front page. The loaded header module has access to this variable as well, and if a login redirect is required, this variable is passed alone as a GET URL parameter (as a part of the "link" property):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
'/login.sql?path=' || $_curpath AS link
|
||||
WHERE $_username IS NULL AND $_session_required;
|
||||
```
|
||||
|
||||
The login form passes it further in a similar fashion to the create_session.sql script (as part of the "action" property):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'form' AS component,
|
||||
'Login' AS title,
|
||||
'create_session.sql' || ifnull('?path=' || $path, '') AS action;
|
||||
```
|
||||
If authentication fails, create_session.sql redirects back to login.sql and makes sure to pass the $path value alone (as a part of the "link" property):
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'authentication' AS component,
|
||||
'login.sql?' || ifnull('path=' || $path, '') || '&error=1' AS link,
|
||||
:password AS password,
|
||||
(SELECT password_hash
|
||||
FROM "accounts"
|
||||
WHERE username = :username) AS password_hash;
|
||||
```
|
||||
|
||||
If authentication succeeds, create_session.sql redirects back to starting page using the $path value as the final redirect target:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
ifnull($path, '/') AS link;
|
||||
```
|
||||
|
||||
### Adding User/Login/Logout buttons to the page menu
|
||||
|
||||
It is customary to show the "User Profile" button in the top right corner when the user is authenticated. Also, it is customary to show the "Logout" button next to the "User Profile" button or the "Login" button when no active session is available. The associated code is common to all pages, and it makes sense to place it in the same header module.
|
||||
|
||||
The "shell" component is responsible for constructing the top menu, but the standard component does not support menu buttons. The simplest solution to this "limitation" is to modify the standard shell.handlebars template found in the "sqlpage/templates" directory of the SQLPage source code repository and place it inside the project "sqlpage/templates" directory.
|
||||
|
||||
To extend the "shell" component with button items in the menu, I have added a hybrid section of code mostly constructed from template code defining menu items and the "button" component. In the present implementation, menu buttons are defined as a JSON array value to the "menu_buttons" property. Each array member is a JSON object defining a single button and may include "shape", "color", "size", "outline", "link", "tooltip", and "title" properties (see description of these properties in the official "button" component docs.
|
||||
|
||||
Note how the `$_curpath` variable, which is set in core page modules (such as currencies_list.sql) is used to define links for the Login/Logout buttons. These links are irrelevant for protected pages, but for non-protected pages, such as intro.sql, these links make sure that the user remains on the same page after he/she presses on Logout/Login buttons (and completes authentication in the latter case).
|
||||
|
||||
The `$_username` variable set during the authentication process is then used to decide which buttons (Login or User/Logout) should be shown.
|
||||
|
||||
The `$_shell_enabled` variable controls the execution of the custom shell component. This feature is necessary because the header module is also loaded by the currencies_item_dml.sql module, which should only be accessible to authenticated users. However, the currencies_item_dml.sql module is a no-GUI module, which performs database operations and uses redirects after the requested operations are completed. At the same time, if the loaded header module executes the custom shell component, generating GUI buttons, the redirection mechanism in currencies_item_dml.sql will fail.
|
||||
|
||||
### Required variable guards
|
||||
|
||||
The header modules expects that the calling module sets several variables. The SET statement makes it possible to check if the variables are set appropriately in one place at the beginning of the module, rather then placing guards every time theses variables are used. Hence, the top section of the header file includes
|
||||
|
||||
```sql
|
||||
set _curpath = ifnull($_curpath, '/');
|
||||
set _session_required = ifnull($_session_required, 1);
|
||||
set _shell_enabled = ifnull($_shell_enabled, 1);
|
||||
```
|
||||
In this case, if any required variable is not set, a suitable default value is defined, so that the following code would not have to check for NULL values. Alternatively, a redirect to an error page may be used, to inform the programmer about the potential issue.
|
||||
|
||||
## Footer module - debug information
|
||||
|
||||
POST/GET/SET variables may provide helpful information for debugging purposes. In earlier [post](https://reddit.com/r/SQLpage/comments/1dh1siw/structuring_code_showing_debug_info/), I described the code I use to output variables in a convenient way. Briefly, I use `sqlpage.variables('GET')` and `sqlpage.variables('POST')` to get all variables, and I distinguish between the GET variables and local SET variables by prefixing SET variable names with an underscore. Initially, I copy-pasted the code snippets at the bottom of pages, but later I moved it to a separate file, footer_debug_post-get-set.sql, which I load via
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties
|
||||
WHERE $DEBUG OR $error IS NOT NULL;
|
||||
```
|
||||
## Structuring code modules
|
||||
|
||||
The "currencies" table is handled by three modules:
|
||||
|
||||
- "table" view - __currencies_list.sql__
|
||||
Displays the entire table using the powerful "table" component. One way to extend this module is, possibly, to hide certain less important columns, especially for wide tables.
|
||||
- "detail" view - __currencies_item_form.sql__
|
||||
This is the "detail" view. It shows all fields for a single record. In this case, it is an "editable" form, though the fields maybe made conditionally read-only. Another possible option for a read-only detail view is to use the "datagrid" component.
|
||||
- database DML processor - __currencies_item_dml.sql__
|
||||
This is a no-GUI module, which only processes database modification operations using data submitted to the currencies_item_form.sql form. Presently, all DML statements (INSERT/UPDATE/DELETE) are processed by this module. If necessary, this module maybe split into more specialized modules.
|
||||
|
||||
Let us briefly go over the code block in these modules.
|
||||
|
||||
### Debug information (bottom section)
|
||||
|
||||
All three module load the footer module discussed above that produces a conditional output of GET/POST/SET variables.
|
||||
|
||||
### Authentication (top section)
|
||||
|
||||
All three modules provide access to the database and are treated as protected: they are only accessible to authenticated users. Hence, they start with (mostly) the same code block:
|
||||
|
||||
```sql
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 1;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
```
|
||||
|
||||
This code discussed above sets the current path variable (necessary for correct redirects), authentication flag before loading the header module that takes care of authentication and common settings, such as top menu buttons.
|
||||
|
||||
The "no-GUI" currencies_item_dml.sql module does not set `$_curpath`, since it cannot be a start/end point in a redirect chain, but it sets the `$_shell_enabled` flag to suppress top menu buttons generation, as discussed earlier.
|
||||
|
||||
### Common variables
|
||||
|
||||
The second section may generally be used to set additional common variables, such as the name of the "table" view inside the "detail" view and the other way around (to switch between the two).
|
||||
|
||||
The "detail" view also uses the "&path" GET URL parameter, if provided (e.g., by the "table" view). This way, if a record is modified/deleted starting from, e.g., the "table" view, the same view is set as the final redirect target after the DML operation is completed.
|
||||
|
||||
### Table view
|
||||
|
||||
The rest of the table view module is fairly basic. It defines two alerts for displaying confirmation and error messages, a "new record" button, and the table itself. The last "actions" column is added to the table, designated as markdown, and includes shortcuts to edit/delete the corresponding record.
|
||||
|
||||

|
||||
|
||||
### Detail view
|
||||
|
||||
The detail view module is more elaborate. If "&id" GET URL parameter is provided, the form shows the corresponding record. Otherwise, the ID field is rendered as a dropdown list populated from the database, but is set to NULL. The remaining fields are either blank or contain dummy values.
|
||||
|
||||
The first step (after the previously discussed common sections), therefore, is to filter invalid id values.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath AS link
|
||||
WHERE $id = '' OR CAST($id AS INT) = 0;
|
||||
|
||||
set error_msg = sqlpage.url_encode('Bad {id = ' || $id || '} provided');
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath || '?error=' || $error_msg AS link
|
||||
WHERE $id NOT IN (SELECT currencies.id FROM currencies);
|
||||
```
|
||||
|
||||
The blank string and zero are considered the equivalents of NULL, so redirect to itself is activated, removing the id parameter. If no id is provided or id is set to an integer value, the first check does not trigger. The second check above triggers when there is no record with provided id. This check resets id and displays an error message.
|
||||
|
||||
Another accepted GET URL parameter is $values, which may be set to a JSON representation of the record. This parameter is returned from the currencies_item_dml.sql script if the database operation fails. Then the detail view will display an error message, but the form will remain populated with the user-submitted data. If $values is set, it takes precedence. This check throws an error if $values is set, but does not represent a valid JSON.
|
||||
|
||||
```sql
|
||||
set _err_msg =
|
||||
sqlpage.url_encode('Values is set to bad JSON: __ ') || $values || ' __';
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath || '?error=' || $_err_msg AS link
|
||||
WHERE NOT json_valid($values);
|
||||
```
|
||||
The detail view maybe called with zero, one, or two (\$id/\$values) parameters. Invalid values are filtered out at this point, so the next step is to check provided parameters and determine the dataset that should go into the form.
|
||||
|
||||
```sql
|
||||
set _values = (
|
||||
WITH
|
||||
fields AS (
|
||||
SELECT id, name, to_rub
|
||||
FROM currencies
|
||||
WHERE id = CAST($id AS INT) AND $values IS NULL
|
||||
UNION ALL
|
||||
SELECT NULL, '@', 1
|
||||
WHERE $id IS NULL AND $values IS NULL
|
||||
UNION ALL
|
||||
SELECT
|
||||
$values ->> '$.id' AS id,
|
||||
$values ->> '$.name' AS name,
|
||||
$values ->> '$.to_rub' AS to_rub
|
||||
WHERE json_valid($values)
|
||||
)
|
||||
SELECT
|
||||
json_object(
|
||||
'id', CAST(fields.id AS INT),
|
||||
'name', fields.name,
|
||||
'to_rub', CAST(CAST(fields.to_rub AS TEXT) AS NUMERIC)
|
||||
)
|
||||
FROM fields
|
||||
);
|
||||
```
|
||||
|
||||
Each of the three united SELECTs in the "fields" CTE returns a single row and only one of them is selected for any given combination of \$id/\$values using the WHERE clauses. This query returns the "final" set of fields as a JSON object.
|
||||
|
||||

|
||||
|
||||
Now that the input parameters are validated and the "final" dataset is determined, it is the time to define the form GUI elements. First, I define the button to switch to the table view. Note that the same form is used to confirm record deletion, and when this happens, the "Browse" button is not shown.
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
'button' AS component,
|
||||
'pill' AS shape,
|
||||
'lg' AS size,
|
||||
'end' AS justify;
|
||||
SELECT
|
||||
'BROWSE' AS title,
|
||||
'browse_rec' AS id,
|
||||
'corner-down-left' AS icon,
|
||||
'corner-down-left' AS icon_after,
|
||||
'green' AS outline,
|
||||
$_table_list AS link,
|
||||
'Browse full table' AS tooltip
|
||||
WHERE NOT ifnull($action = 'DELETE', FALSE);
|
||||
```
|
||||
|
||||
The following section defines the main form with record fields. First the $\_valid_ids variable is constructed as the source for the drop-down id field. The code also adds the NULL value used for defining a new record. Note that, when this form is opened from the table view via the "New Record" button, the $action variable is set to "INSERT" and the id field is set to the empty array in the first assignment via the alternative UINION and to the single NULL in the second assignment. The two queries can also be combined relatively straightforwardly using CTEs.
|
||||
|
||||
```sql
|
||||
set _valid_ids = (
|
||||
SELECT json_group_array(
|
||||
json_object('label', CAST(id AS TEXT), 'value', id) ORDER BY id
|
||||
)
|
||||
FROM currencies
|
||||
WHERE ifnull($action, '') <> 'INSERT'
|
||||
UNION ALL
|
||||
SELECT '[]'
|
||||
WHERE $action = 'INSERT'
|
||||
);
|
||||
set _valid_ids = (
|
||||
json_insert($_valid_ids, '$[#]',
|
||||
json_object('label', 'NULL', 'value', json('null'))
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
The next part defines form fields via the "dynamic" component (for some reason I am having issues with POST variables when the form is defined directly via the "form" component. Note how the $values variable prepared in previous blocks is used to populate the form. Without the SET statement, everything would need to be incorporated in a single query (which is feasible thanks to CTEs, but would still be significantly more difficult to develop and maintain).
|
||||
|
||||
Also note that this single form definition actually combines two forms (the second being the record delete confirmation form). If the $action variable is set to "DELETE" (after the delete operation is initiated from either the table or detail view), buttons are adjusted appropriately and all fields are set to read-only. Whether this is a good design is a separate question. Perhaps, defining two separate forms is a better approach.
|
||||
|
||||

|
||||
|
||||
|
||||
After the main form fields goes the delete confirmation alert, displayed after the delete operation is completed.
|
||||
|
||||
The last big section defines the main form buttons, which are adjusted based on the type of operation (similarly to the form fields above).
|
||||
|
||||
The final section includes a general confirmation alert (used after INSERT/UPDATE operations) and an error alert.
|
||||
|
||||
### Coding style conventions
|
||||
|
||||
Consistent code style is important for code readability. Because SQLPage module maybe a mix of SQL code and sizeable text fragments, which may contain plain text, Markdown, JSON, HTML, etc., it might be difficult to follow a fixed set of rules. In fact, dynamically generated webpages regardless of specific technologies used tend to get messy. At the very least I strive to
|
||||
|
||||
- keep all SQL keywords always in the UPPER case,
|
||||
- have reasonably sensible code alignment (though some alignment approaches may not be generally advisable)
|
||||
- keep large static text pieces in separate appropriate dedicated files and load them via `sqlpage.read_file_as_text()` (e.g., the text of this file comes from Readme.md, where it can be properly edited by any Markdown editor and version-controlled; similarly, static JSON should go in \*.json files or in a dedicated database table with a designated JSON column).
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Redirect to the login page if the password is not correct
|
||||
|
||||
SELECT
|
||||
'authentication' AS component,
|
||||
'login.sql?' || ifnull('path=' || sqlpage.url_encode($path), '') || '&error=1' AS link,
|
||||
:password AS password,
|
||||
(SELECT password_hash
|
||||
FROM accounts
|
||||
WHERE username = :username) AS password_hash;
|
||||
|
||||
-- The code after this point is only executed if the user has sent the correct password
|
||||
-- Generate a random session token and set via the "cookie" component in the RETURNING
|
||||
-- clause.
|
||||
|
||||
INSERT INTO sessions (id, username)
|
||||
VALUES (sqlpage.random_string(32), :username)
|
||||
RETURNING
|
||||
'cookie' AS component,
|
||||
'session_token' AS name,
|
||||
id AS value;
|
||||
|
||||
-- The user browser will now have a cookie named `session_token` that we can check later
|
||||
-- to see if the user is logged in.
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
ifnull($path, '/') AS link;
|
||||
@@ -0,0 +1,92 @@
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: var(--tblr-gray-300);
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: var(--tblr-gray-500);
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag {
|
||||
color: #f92672;
|
||||
|
||||
/* We need to reset the 'tag' styles set by tabler */
|
||||
border: 0;
|
||||
display: inherit;
|
||||
height: inherit;
|
||||
border-radius: inherit;
|
||||
padding: 0;
|
||||
background: inherit;
|
||||
box-shadow: inherit;
|
||||
}
|
||||
|
||||
.token.number {
|
||||
color: #ea9999;
|
||||
}
|
||||
|
||||
.token.boolean {
|
||||
color: #ae81ff;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string {
|
||||
color: #97e1a3;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value {
|
||||
color: #e6db74;
|
||||
}
|
||||
|
||||
.token.keyword {
|
||||
color: #95d1ff;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important {
|
||||
color: var(--tblr-yellow);
|
||||
}
|
||||
|
||||
.token.important {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.token {
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
code::selection,
|
||||
code ::selection {
|
||||
background: var(--tblr-yellow);
|
||||
color: var(--tblr-gray-900);
|
||||
border-radius: 0.1em;
|
||||
}
|
||||
|
||||
code .token.keyword::selection,
|
||||
code .token.punctuation::selection {
|
||||
color: var(--tblr-gray-800);
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.menu_options_slim {
|
||||
min-width: inherit !important;
|
||||
}
|
||||
|
||||
.menu_language_slim {
|
||||
min-width: inherit !important;
|
||||
}
|
||||
|
||||
.menu_language {
|
||||
min-width: 200%;
|
||||
}
|
||||
|
||||
div.dropdown-menu- {
|
||||
min-width: inherit !important;
|
||||
}
|
||||
|
||||
a.dropdown-item- {
|
||||
min-width: inherit !important;
|
||||
}
|
||||
|
||||
.slim_item {
|
||||
min-width: inherit !important;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
-- Procesess CREATE/UPDATE/DELETE operations.
|
||||
|
||||
-- =============================================================================
|
||||
-- =========================== Module Setting ==================================
|
||||
-- =========================== Login / Logout ==================================
|
||||
-- =============================================================================
|
||||
|
||||
-- $_curpath and $_session_required are required for header_shell_session.sql.
|
||||
|
||||
set _session_required = 1;
|
||||
set _shell_enabled = 0;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
|
||||
-- =============================================================================
|
||||
-- Redirect target must be passed as $path
|
||||
-- =============================================================================
|
||||
|
||||
set _err_msg = '&path URL GET parameter (redirect target) is not set!';
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'red' AS color,
|
||||
'alert-triangle' AS icon,
|
||||
'Error' AS title,
|
||||
$_err_msg AS description,
|
||||
TRUE AS dismissible
|
||||
WHERE
|
||||
$path IS NULL AND $DEBUG IS NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- Check new values for validity:
|
||||
-- - UPDATE existing record:
|
||||
-- :id IS NOT NULL
|
||||
-- If :name is in the database, :id must match
|
||||
-- If attempting to change :name, operation may fail due to FK constraint
|
||||
-- - INSERT new record:
|
||||
-- :id IS NULL
|
||||
-- :name is not in the database
|
||||
-- =============================================================================
|
||||
|
||||
-- Pass new values back as JSON object in $values GET variable for form population.
|
||||
--
|
||||
-- For new records, the id (INTEGER PRIMARY KEY AUTOINCREMENT) should be set to NULL.
|
||||
-- The id field is set as hidden in the record edit form and passed as the :id POST
|
||||
-- variable. NULL, however, cannot be passed as such and is converted to blank string.
|
||||
-- Check :id for '' and set id (:id will return the same value).
|
||||
|
||||
set _id = iif(typeof(:id) = 'text' AND :id = '', NULL, :id);
|
||||
|
||||
set _values = json_object(
|
||||
'id', CAST($_id AS INT),
|
||||
'name', :name,
|
||||
'to_rub', CAST(:to_rub AS NUMERIC)
|
||||
);
|
||||
|
||||
set _op = iif($_id IS NULL, 'INSERT', 'UPDATE');
|
||||
set _err_msg = sqlpage.url_encode('New currency already in the database');
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$path || '?' ||
|
||||
'&op=' || $_op ||
|
||||
'&values=' || $_values ||
|
||||
'&error=' || $_err_msg AS link
|
||||
FROM currencies
|
||||
WHERE currencies.name = :name
|
||||
AND ($_id IS NULL OR currencies.id <> $_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- UPSERT: If everything is OK and "UPDATE" is indicated, update the database
|
||||
-- =============================================================================
|
||||
|
||||
INSERT INTO currencies(id, name, to_rub)
|
||||
SELECT CAST($_id AS INT), :name, CAST(:to_rub AS NUMERIC)
|
||||
WHERE $action = 'UPDATE'
|
||||
ON CONFLICT(id) DO
|
||||
UPDATE SET name = excluded.name, to_rub = excluded.to_rub
|
||||
RETURNING
|
||||
'redirect' AS component,
|
||||
$path || '?' ||
|
||||
'&id=' || id ||
|
||||
'&info=' || $_op || ' completed successfully' AS link;
|
||||
|
||||
-- =============================================================================
|
||||
-- DELETE
|
||||
-- =============================================================================
|
||||
|
||||
DELETE FROM currencies
|
||||
WHERE $action = 'DELETE' AND id = $_id
|
||||
RETURNING
|
||||
'redirect' AS component,
|
||||
$path || '?' ||
|
||||
'&info=DELETE completed successfully' AS link;
|
||||
|
||||
-- =============================================================================
|
||||
-- DEBUG
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties;
|
||||
@@ -0,0 +1,309 @@
|
||||
-- Reads an item from the database if valid id is provided and
|
||||
-- populates the form. Otherwise, an empty form is presented.
|
||||
|
||||
-- =============================================================================
|
||||
-- =========================== Module Setting ==================================
|
||||
-- =========================== Login / Logout ==================================
|
||||
-- =============================================================================
|
||||
|
||||
-- $_curpath and $_session_required are required for header_shell_session.sql.
|
||||
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 1;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
|
||||
-- =============================================================================
|
||||
-- =============================== Module vars =================================
|
||||
-- =============================================================================
|
||||
|
||||
set _getpath = '?path=' || ifnull($path, $_curpath);
|
||||
set _action_target = 'currencies_item_dml.sql' || $_getpath;
|
||||
set _table_list = 'currencies_list.sql';
|
||||
|
||||
-- =============================================================================
|
||||
-- ========================== Filter invalid $id ===============================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- NULL is passed as 0 or '' via POST
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath AS link
|
||||
WHERE $id = '' OR CAST($id AS INT) = 0;
|
||||
|
||||
-- If $id is set, it must be a valid PKEY value.
|
||||
|
||||
set error_msg = sqlpage.url_encode('Bad {id = ' || $id || '} provided');
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath || '?error=' || $error_msg AS link
|
||||
|
||||
-- If $id IS NULL, NOT IN returns NULL and redirect is NOT selected.
|
||||
|
||||
WHERE $id NOT IN (SELECT currencies.id FROM currencies);
|
||||
|
||||
-- =============================================================================
|
||||
-- ======================== Filter invalid $values =============================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- If $values is provided, it must contain a valid JSON.
|
||||
|
||||
set _err_msg =
|
||||
sqlpage.url_encode('Values is set to bad JSON: __ ') || $values || ' __';
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
$_curpath || '?error=' || $_err_msg AS link
|
||||
|
||||
-- Covers $values IS NULL..
|
||||
|
||||
WHERE NOT json_valid($values);
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================= Prepare data ==================================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Field values may be provided via the $values GET variable formatted as JSON
|
||||
-- object. If $values contains a valid JSON, use it to populate the form.
|
||||
-- Otherwise, if $id is set to a valid value, retrieve the record from the
|
||||
-- database and set values. If not, set values to all NULLs.
|
||||
|
||||
set _values = (
|
||||
WITH
|
||||
fields AS (
|
||||
-- If valid "id" is supplied as a GET variable, retrieve the record and
|
||||
-- populate the form.
|
||||
|
||||
SELECT id, name, to_rub
|
||||
FROM currencies
|
||||
WHERE id = CAST($id AS INT) AND $values IS NULL
|
||||
|
||||
-- If no "id" is supplied, the first part does not return any records,
|
||||
-- so add a dummy record.
|
||||
|
||||
UNION ALL
|
||||
SELECT NULL, '@', 1
|
||||
WHERE $id IS NULL AND $values IS NULL
|
||||
|
||||
-- If $value contains a valid JSON, use it to populate the form
|
||||
|
||||
UNION ALL
|
||||
SELECT
|
||||
$values ->> '$.id' AS id,
|
||||
$values ->> '$.name' AS name,
|
||||
$values ->> '$.to_rub' AS to_rub
|
||||
WHERE json_valid($values)
|
||||
)
|
||||
SELECT
|
||||
json_object(
|
||||
'id', CAST(fields.id AS INT),
|
||||
'name', fields.name,
|
||||
'to_rub', CAST(CAST(fields.to_rub AS TEXT) AS NUMERIC)
|
||||
)
|
||||
FROM fields
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- ========================= Browse Records Button =============================
|
||||
-- =============================================================================
|
||||
--
|
||||
SELECT
|
||||
'button' AS component,
|
||||
'square' AS shape,
|
||||
'sm' AS size,
|
||||
'end' AS justify;
|
||||
SELECT
|
||||
'BROWSE' AS title,
|
||||
'browse_rec' AS id,
|
||||
'corner-down-left' AS icon,
|
||||
'corner-down-left' AS icon_after,
|
||||
'green' AS outline,
|
||||
TRUE AS narrow,
|
||||
$_table_list AS link,
|
||||
'Browse full table' AS tooltip
|
||||
WHERE NOT ifnull($action = 'DELETE', FALSE);
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================== Main Form ====================================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- When confirming record deletion, set all fields to read-only and id type to
|
||||
-- number. No need to worry about the field values: all fields. including id are
|
||||
-- passed back as POST variables, and the code above sets the $_values variable
|
||||
-- for proper initialization of the reloaded form.
|
||||
|
||||
set _valid_ids = (
|
||||
SELECT json_group_array(
|
||||
json_object('label', CAST(id AS TEXT), 'value', id) ORDER BY id
|
||||
)
|
||||
FROM currencies
|
||||
WHERE ifnull($action, '') <> 'INSERT'
|
||||
UNION ALL
|
||||
SELECT '[]'
|
||||
WHERE $action = 'INSERT'
|
||||
);
|
||||
set _valid_ids = (
|
||||
json_insert($_valid_ids, '$[#]',
|
||||
json_object('label', 'NULL', 'value', json('null'))
|
||||
)
|
||||
);
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
json_array(
|
||||
json_object(
|
||||
'component', 'form',
|
||||
'title', 'Currency',
|
||||
'class', 'form_class',
|
||||
'id', 'detail_view',
|
||||
'validate', '',
|
||||
'action', $_action_target
|
||||
),
|
||||
json_object(
|
||||
'name', 'id',
|
||||
'label', 'ID',
|
||||
'type', iif(ifnull($action = 'DELETE', FALSE), 'number', 'select'),
|
||||
'name', 'id',
|
||||
'value', $_values ->> '$.id',
|
||||
'options', $_valid_ids,
|
||||
'width', 4,
|
||||
'readonly', ifnull($action = 'DELETE', FALSE),
|
||||
'required', json('false')
|
||||
),
|
||||
json_object(
|
||||
'name', 'name',
|
||||
'label', 'Currency',
|
||||
'value', $_values ->> '$.name',
|
||||
'placeholder', 'RUR',
|
||||
'width', 4,
|
||||
'readonly', ifnull($action = 'DELETE', FALSE),
|
||||
'required', json('true')
|
||||
),
|
||||
json_object(
|
||||
'type', 'number',
|
||||
'step', 0.01,
|
||||
'name', 'to_rub',
|
||||
'label', 'Exchange Rate to RUR',
|
||||
'value', $_values ->> '$.to_rub',
|
||||
'placeholder', 1,
|
||||
'width', 4,
|
||||
'readonly', ifnull($action = 'DELETE', FALSE),
|
||||
'required', json('true')
|
||||
)
|
||||
) AS properties
|
||||
;
|
||||
|
||||
-- =============================================================================
|
||||
-- ===================== Display DELETE confirmation ===========================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'warning' AS color,
|
||||
'alert-triangle' AS icon,
|
||||
TRUE AS important,
|
||||
'Warning' AS title,
|
||||
'Confirm record deletion' AS description
|
||||
WHERE $action = 'DELETE';
|
||||
|
||||
-- =============================================================================
|
||||
-- ========================== Main Form Buttons ================================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- When confirming record deletion, disable the UPDATE button, replace
|
||||
-- the Reload button with the Cancel button, invert the DELETE button by
|
||||
-- removing the outline color, and ajust the POST target.
|
||||
|
||||
|
||||
SELECT
|
||||
'button' AS component,
|
||||
'pill' AS shape,
|
||||
'' AS size,
|
||||
'center' AS justify;
|
||||
|
||||
SELECT -- Default button
|
||||
'(Re)load' AS title,
|
||||
'read_rec' AS id,
|
||||
'database' AS icon,
|
||||
'database' AS icon_after,
|
||||
'green' AS outline,
|
||||
TRUE AS narrow,
|
||||
$_curpath AS link,
|
||||
'detail_view' AS form,
|
||||
TRUE AS space_after
|
||||
WHERE NOT ifnull($action = 'DELETE', FALSE);
|
||||
|
||||
SELECT -- Cancel DELETE button
|
||||
'Cancel' AS title,
|
||||
'read_rec' AS id,
|
||||
'alert-triangle' AS icon,
|
||||
'alert-triangle' AS icon_after,
|
||||
'primary' AS color,
|
||||
TRUE AS narrow,
|
||||
$_curpath AS link,
|
||||
'detail_view' AS form,
|
||||
TRUE AS space_after
|
||||
WHERE ifnull($action = 'DELETE', FALSE);
|
||||
|
||||
SELECT
|
||||
'Update' AS title, -- UPDATE / INSERT button
|
||||
'update_rec' AS id,
|
||||
'device-floppy' AS icon,
|
||||
'device-floppy' AS icon_after,
|
||||
'azure' AS outline,
|
||||
TRUE AS narrow,
|
||||
$_action_target || '&action=UPDATE' AS link,
|
||||
'detail_view' AS form,
|
||||
ifnull($action = 'DELETE', FALSE) AS disabled,
|
||||
TRUE AS space_after;
|
||||
|
||||
SELECT -- DELETE button
|
||||
'DELETE' AS title,
|
||||
'delete_rec' AS id,
|
||||
'alert-triangle' AS icon,
|
||||
'trash' AS icon_after,
|
||||
TRUE AS narrow,
|
||||
iif(ifnull($action = 'DELETE', FALSE), NULL, 'danger') AS outline,
|
||||
iif(ifnull($action = 'DELETE', FALSE),
|
||||
$_action_target, $_curpath || '?') || '&action=DELETE' AS link,
|
||||
'danger' AS color,
|
||||
'detail_view' AS form,
|
||||
FALSE AS space_after;
|
||||
|
||||
-- =============================================================================
|
||||
-- ======================== Display confirmation ===============================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'green' AS color,
|
||||
'check' AS icon,
|
||||
'Success' AS title,
|
||||
$info AS description,
|
||||
True AS dismissible
|
||||
WHERE $info IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- ======================== Display error message ==============================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'red' AS color,
|
||||
'thumb-down' AS icon,
|
||||
$op || ' error' AS title,
|
||||
$error AS description,
|
||||
True AS dismissible
|
||||
WHERE $error IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- DEBUG
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties;
|
||||
@@ -0,0 +1,114 @@
|
||||
-- =============================================================================
|
||||
-- =========================== Module Setting ==================================
|
||||
-- =========================== Login / Logout ==================================
|
||||
-- =============================================================================
|
||||
|
||||
-- $_curpath and $_session_required are required for header_shell_session.sql.
|
||||
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 1;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
|
||||
-- =============================================================================
|
||||
-- =============================== Module vars =================================
|
||||
-- =============================================================================
|
||||
|
||||
set _getpath = '&path=' || $_curpath;
|
||||
set _item_form = 'currencies_item_form.sql';
|
||||
|
||||
-- =============================================================================
|
||||
-- ======================== Display confirmation ===============================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'green' AS color,
|
||||
'check' AS icon,
|
||||
'Success' AS title,
|
||||
$info AS description,
|
||||
TRUE AS dismissible
|
||||
WHERE $info IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- ======================== Display error message ==============================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'red' AS color,
|
||||
'thumb-down' AS icon,
|
||||
$op || ' error' AS title,
|
||||
$error AS description,
|
||||
TRUE AS dismissible
|
||||
WHERE $error IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
-- ========================== New record button ================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'button' AS component,
|
||||
'pill' AS shape,
|
||||
'lg' AS size,
|
||||
'end' AS justify;
|
||||
SELECT
|
||||
'New Record' AS title,
|
||||
'insert_rec' AS id,
|
||||
'circle-plus' AS icon,
|
||||
'circle-plus' AS icon_after,
|
||||
'green' AS outline,
|
||||
$_item_form || '?' || $_getpath || '&action=INSERT' AS link
|
||||
;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================= Show the table ================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'currencies' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'title' AS component,
|
||||
'Currencies' AS contents,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id,
|
||||
'actions' AS markdown;
|
||||
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
to_rub,
|
||||
'[](' || $_item_form || '?' || $_getpath || '&id=' || id || ') ' ||
|
||||
'[](' || $_item_form || '?' || $_getpath || '&id=' || id || '&action=DELETE)' AS actions
|
||||
FROM currencies
|
||||
ORDER BY id;
|
||||
|
||||
-- =============================================================================
|
||||
-- DEBUG
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- =============================================================================
|
||||
-- Displays GET/POST/SET variables in sorted tables for debug purposes. The
|
||||
-- variables are displayed if URL GET variable &DEBUG=1 is set OR &error is
|
||||
-- defined and not empty.
|
||||
--
|
||||
-- ## Usage
|
||||
--
|
||||
-- Execute this script at the bottom of a page script via
|
||||
--
|
||||
-- ```sql
|
||||
-- SELECT
|
||||
-- 'dynamic' AS component,
|
||||
-- sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties;
|
||||
-- ```
|
||||
|
||||
-- GET VARIABLES --
|
||||
|
||||
SELECT
|
||||
'title' AS component,
|
||||
'GET Variables' AS contents,
|
||||
3 AS level,
|
||||
TRUE AS center
|
||||
WHERE $DEBUG OR $error IS NOT NULL;
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
FALSE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
'value' AS markdown
|
||||
WHERE $DEBUG OR $error IS NOT NULL;
|
||||
|
||||
SELECT "key" AS variable, value
|
||||
FROM json_each(sqlpage.variables('GET'))
|
||||
WHERE $DEBUG OR $error IS NOT NULL
|
||||
ORDER BY substr("key", 1, 1) = '_', "key";
|
||||
|
||||
|
||||
-- POST VARIABLES --
|
||||
|
||||
SELECT
|
||||
'title' AS component,
|
||||
'POST Variables' AS contents,
|
||||
3 AS level,
|
||||
TRUE AS center
|
||||
WHERE $DEBUG OR $error IS NOT NULL;
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
FALSE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
'value' AS markdown
|
||||
WHERE $DEBUG OR $error IS NOT NULL;
|
||||
|
||||
SELECT "key" AS variable, value
|
||||
FROM json_each(sqlpage.variables('POST'))
|
||||
WHERE $DEBUG OR $error IS NOT NULL
|
||||
ORDER BY "key";
|
||||
@@ -0,0 +1,175 @@
|
||||
-- =============================================================================
|
||||
-- Checks for the availablity of an active session and redirects to the login
|
||||
-- page, if necessary. Using a customized shell_ex component, shows "user" and
|
||||
-- login/logout buttons appropriately in the top-right corner.
|
||||
--
|
||||
-- Note, any additonal "shell" component settings must also be included here in
|
||||
-- the same component. It may require extending this script in a flexible way or
|
||||
-- creating a page-specific copy, which is less desirable, as it would cause code
|
||||
-- duplication.
|
||||
--
|
||||
-- ## Usage
|
||||
--
|
||||
-- Execute this script via
|
||||
--
|
||||
-- ```sql
|
||||
-- SELECT
|
||||
-- 'dynamic' AS component,
|
||||
-- sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
-- ```
|
||||
--
|
||||
-- at the top of the page script, but AFTER setting the required variables
|
||||
--
|
||||
-- ```sql
|
||||
-- set _curpath = sqlpage.path();
|
||||
-- set _session_required = 1;
|
||||
-- set _shell_enabled = 1;
|
||||
-- ```
|
||||
--
|
||||
-- ## Reuired SET Variables
|
||||
--
|
||||
-- $_curpath
|
||||
-- - indicates redirect target passed to the login script
|
||||
-- $_session_required
|
||||
-- - 1 - require valid active session for non-public pages
|
||||
-- - 0 - ignore active session for public pages
|
||||
-- $_shell_enabled
|
||||
-- - 1 - execute the shell component in this script (default, if not defined)
|
||||
-- - 0 - do not execute the shell component in this script
|
||||
-- Define this value to use page-specific shell component.
|
||||
-- It id also necessary for no-GUI pages, which are called via a redirect and
|
||||
-- normally redirect back after the necessary processing is completed. Such
|
||||
-- pages may still require this script to check for active session, but they
|
||||
-- will not be able to redirect back if this script outputs GUI buttons.
|
||||
|
||||
-- =============================================================================
|
||||
-- ======================= Check required variables ============================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Set default values (for now) for required variables.
|
||||
-- Probably should instead show appropriate error messages and abort rendering.
|
||||
|
||||
set _curpath = ifnull($_curpath, '/');
|
||||
set _session_required = ifnull($_session_required, 1);
|
||||
set _shell_enabled = ifnull($_shell_enabled, 1);
|
||||
|
||||
-- =============================================================================
|
||||
-- ========================= Check active session ==============================
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Check if session is available.
|
||||
-- Require the user to log in again after 1 day
|
||||
|
||||
set _username = (
|
||||
SELECT username
|
||||
FROM sessions
|
||||
WHERE sqlpage.cookie('session_token') = id
|
||||
AND created_at > datetime('now', '-1 day')
|
||||
);
|
||||
|
||||
-- Redirect to the login page if the user is not logged in.
|
||||
-- Unprotected pages must
|
||||
-- set _session_required = (SELECT FALSE);
|
||||
-- before running this script
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
'/login.sql?path=' || $_curpath AS link
|
||||
WHERE $_username IS NULL AND $_session_required;
|
||||
|
||||
-- =============================================================================
|
||||
-- ==================== Add User and Login/Logout buttons ======================
|
||||
-- =============================================================================
|
||||
--
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
json_array(
|
||||
json_object(
|
||||
'component', 'shell',
|
||||
'title', 'CRUD with Authentication',
|
||||
'icon', 'database',
|
||||
'description', 'Description',
|
||||
'layout', 'fluid',
|
||||
|
||||
'css',
|
||||
json_array(
|
||||
'/css/prism-tabler-theme.css', -- Load for code highlighting
|
||||
'/css/style.css'
|
||||
),
|
||||
|
||||
'javascript',
|
||||
json_array(
|
||||
|
||||
-- Code highlighting scripts
|
||||
|
||||
'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js'
|
||||
),
|
||||
|
||||
'menu_item',
|
||||
iif($_username IS NOT NULL,
|
||||
json_array(
|
||||
json_object(
|
||||
'button', FALSE,
|
||||
'title', 'Settings',
|
||||
'icon', 'settings',
|
||||
'submenu', json_array(
|
||||
json_object(
|
||||
'button', TRUE,
|
||||
'title', '',
|
||||
'icon', 'user-circle',
|
||||
'shape', 'pill',
|
||||
'size', 'sm',
|
||||
'narrow', TRUE,
|
||||
'color', 'yellow',
|
||||
'outline', '',
|
||||
'link', '#',
|
||||
'tooltip', 'User profile - Not Implemented'
|
||||
),
|
||||
json_object(
|
||||
'button', TRUE,
|
||||
'title', '',
|
||||
'icon', 'logout',
|
||||
'shape', 'pill',
|
||||
'size', 'sm',
|
||||
'narrow', TRUE,
|
||||
'color', 'green',
|
||||
'outline', '',
|
||||
'link', '/logout.sql?path=' || $_curpath,
|
||||
'tooltip', 'Logout'
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
json_array(
|
||||
json_object(
|
||||
'button', TRUE,
|
||||
'title', '',
|
||||
'icon', 'user-scan',
|
||||
'shape', 'pill',
|
||||
'size', 'sm',
|
||||
'narrow', TRUE,
|
||||
'color', 'warning',
|
||||
'outline', '',
|
||||
'link', '#',
|
||||
'tooltip', 'Sign Up - Not Implemented'
|
||||
),
|
||||
json_object(
|
||||
'button', TRUE,
|
||||
'title', '',
|
||||
'icon', 'login',
|
||||
'shape', 'pill',
|
||||
'size', 'sm',
|
||||
'narrow', TRUE,
|
||||
'color', '',
|
||||
'outline', 'cyan',
|
||||
'link', '/login.sql?path=' || $_curpath,
|
||||
'tooltip', 'Login'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
)
|
||||
) AS properties
|
||||
WHERE CAST($_shell_enabled AS INT) <> 0;
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 122.88" style="enable-background:new 0 0 122.88 122.88" xml:space="preserve"><style type="text/css"><![CDATA[
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#A1E367;}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#2394E0;}
|
||||
]]></style><g><path class="st1" d="M61.44,0c33.93,0,61.44,27.51,61.44,61.44c0,33.93-27.51,61.44-61.44,61.44C27.51,122.88,0,95.37,0,61.44 C0,27.51,27.51,0,61.44,0L61.44,0L61.44,0z"/><path class="st0" d="M11.76,93.18c-3.62-5.49-6.33-11.64-7.92-18.23l10.82,5.22l0.06,3.23c0,1.19-2.02,3.71-2.67,4.67L11.76,93.18 L11.76,93.18L11.76,93.18z M78.31,6.5c20.62,6.85,36.09,25,39.07,47.1l-1.95-0.21c-0.35,1.5-0.67,1.53-0.67,3.33c0,1.59,2,2.65,2,6 c0,0.9-2.11,2.69-2.2,3.01l-3.13-3.67v4.67l-0.47-0.02l-0.84-8.62l-1.75,0.55l-2.08-6.41l-6.85,7.16l-0.08,5.24l-2.24,1.5 l-2.38-13.44l-1.42,1.04l-3.22-4.35l-4.81,0.14l-1.84-2.11l-1.89,0.52l-3.71-4.25l-0.72,0.49l2.3,5.88h2.67v-1.33l1.33,0 c0.96,2.66,2,1.08,2,2.67c0,5.55-6.85,9.63-11.34,10.67c0.24,1,0.15,2,1.33,2c2.51,0,1.26-0.44,4-0.67 c-0.13,5.67-6.5,12.44-9.22,16.65l1.22,8.69c0.32,1.89-3.92,3.88-5.36,6.01l0.69,3.33l-1.95,0.79c-0.34,3.42-3.66,7.21-7.39,7.21 h-4c0-4.68-3.34-11.37-3.34-14.68c0-2.81,1.33-3.19,1.33-6.67c0-3.22-3.33-7.83-3.33-8.67v-5.34H45.4c-0.4-1.49-0.15-2-2-2h-0.67 c-2.91,0-2.42,1.33-5.34,1.33h-2.67c-2.41,0-6.67-7.72-6.67-8.67v-8c0-3.45,3.16-7.21,5.34-8.67v-3.33l3-3.05l1.67-0.28 c3.58,0,3.15-2,5.34-2l6,0v4.67l6.6,2.82l0.62-2.85c2.99,0.7,3.77,2.03,7.45,2.03h1.33c2.53,0,2.67-3.36,2.67-6l-5.34,0.53 l-2.33-5.06l-2.31,0.61c0.42,1.81,0.64,1.06,0.64,2.59c0,0.9-0.74,1-1.34,1.33l-2.31-5.86l-4.97-3.55l-0.66,0.65l4.23,4.45 c-0.56,1.6-0.63,6.21-2.96,2.98l2.18-1.05l-5.44-5.7l-3.26,1.27l-3.22,3.08c-0.34,2.48-1.01,3.73-3.61,3.73 c-1.73,0-0.69-0.45-3.34-0.67v-6.67h6l-1.95-4.44l-0.72,0.44v-1.34l9.75-4.49c-0.18-1.4-0.41-0.65-0.41-2.18 c0-0.09,0.65-1.32,0.67-1.34l2.52,1.57l-0.6-2.87l-3.89,0.8l-0.72-3.49c3.08-1.62,9.87-7.34,12.03-7.34l2,0 c2.11,0,7.75,2.08,8.67,3.33l-5.35-0.54l3.97,3.27l0.38-1.4l2.96-0.81l0.04-1.85h1.34v2L78.31,6.5L78.31,6.5L78.31,6.5z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-device-floppy"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2" />
|
||||
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M14 4l0 4l-6 0l0 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1,21 @@
|
||||
<!--
|
||||
category: Design
|
||||
tags: [pencil, change, update]
|
||||
version: "1.0"
|
||||
unicode: "ea98"
|
||||
-->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M7 7h-1a2 2 0 0 0 -2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2 -2v-1" />
|
||||
<path d="M20.385 6.585a2.1 2.1 0 0 0 -2.97 -2.97l-8.415 8.385v3h3l8.385 -8.415z" />
|
||||
<path d="M16 5l3 3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-table"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M3 5a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-14z" />
|
||||
<path d="M3 10h18" />
|
||||
<path d="M10 3v18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 470 B |
@@ -0,0 +1,19 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-trash"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M4 7l16 0" />
|
||||
<path d="M10 11l0 6" />
|
||||
<path d="M14 11l0 6" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
|
||||
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 522 B |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,34 @@
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
json_array(
|
||||
json_object(
|
||||
'component', 'shell',
|
||||
'title', 'CRUD with Authentication',
|
||||
'icon', 'database',
|
||||
'description', 'Description',
|
||||
|
||||
'css',
|
||||
json_array(
|
||||
'/css/prism-tabler-theme.css'
|
||||
),
|
||||
|
||||
'javascript',
|
||||
json_array(
|
||||
'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js',
|
||||
'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js'
|
||||
)
|
||||
|
||||
)
|
||||
) AS properties;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'text' AS component,
|
||||
TRUE AS center,
|
||||
2 AS level,
|
||||
'Demo/Template CRUD with Authentication' AS title,
|
||||
sqlpage.read_file_as_text('./README.md') AS contents_md;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
-- =============================================================================
|
||||
-- =========================== Module Setting ==================================
|
||||
-- =========================== Login / Logout ==================================
|
||||
-- =============================================================================
|
||||
|
||||
-- $_curpath and $_session_required are required for header_shell_session.sql.
|
||||
|
||||
set _curpath = sqlpage.path();
|
||||
set _session_required = 0;
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('header_shell_session.sql') AS properties;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'text' AS component,
|
||||
TRUE AS center,
|
||||
2 AS level,
|
||||
'SQLite Introspection Information' AS title;
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Password Hash' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
-- Password Hash
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'green' AS color,
|
||||
'edit' AS icon,
|
||||
'Password Hash: sqlpage.hash_password(''admin'')' AS title,
|
||||
sqlpage.hash_password('admin') AS description,
|
||||
TRUE AS dismissible;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Alert Template =================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Alert Template' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
-- ALERT
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'green' AS color,
|
||||
'Alert Title' AS title,
|
||||
'Description' AS description,
|
||||
'**Bold MD**' AS description_md,
|
||||
'alert_class' AS class,
|
||||
'alert_id' AS id,
|
||||
TRUE AS dismissible,
|
||||
FALSE AS important,
|
||||
'user' AS icon,
|
||||
'https://google.com' AS link,
|
||||
'LINK TEXT' AS link_text;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ IDs ============================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'IDs' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'title' AS component,
|
||||
'IDs' AS contents,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT
|
||||
sqlite_version() AS "SQLite Version",
|
||||
(SELECT * FROM pragma_application_id()) AS app_id,
|
||||
(SELECT * FROM pragma_user_version()) AS user_version,
|
||||
(SELECT * FROM pragma_schema_version()) AS schema_version;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ SQLite_Master ==================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'SQLite_Master' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'title' AS component,
|
||||
'SQLite_Master' AS contents,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
TRUE AS overflow;
|
||||
|
||||
SELECT * FROM sqlite_master;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Function List ==================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Function List' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'Function List. Total ' || (SELECT count(DISTINCT name)
|
||||
FROM pragma_function_list())
|
||||
|| ' distinct' AS contents,
|
||||
'title' AS component,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT * FROM pragma_function_list() ORDER BY name, narg;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Collation List =================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Collation List' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'Collation List' AS contents,
|
||||
'title' AS component,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT * FROM pragma_collation_list() ORDER BY rowid;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Pragma List ====================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Pragma List' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'Pragma List. Total ' || (SELECT count(*) FROM pragma_pragma_list()) AS contents,
|
||||
'title' AS component,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT * FROM pragma_pragma_list() AS functions ORDER BY name;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Module List ====================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Pragma List' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'Module List. Total ' || (SELECT count(*) FROM pragma_module_list()) AS contents,
|
||||
'title' AS component,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT * FROM pragma_module_list() ORDER BY name;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Table List =====================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Table List' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'Table List' AS contents,
|
||||
'title' AS component,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT * FROM pragma_table_list() ORDER BY type, name;
|
||||
|
||||
-- =============================================================================
|
||||
-- ============================ Database List ==================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'divider' AS component,
|
||||
'Database List' AS contents;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'Database List' AS contents,
|
||||
'title' AS component,
|
||||
4 AS level,
|
||||
TRUE AS center,
|
||||
'title_class' AS class,
|
||||
'title_id' AS id;
|
||||
|
||||
-- =============================================================================
|
||||
-- TABLE
|
||||
|
||||
SELECT
|
||||
'table' AS component,
|
||||
TRUE AS sort,
|
||||
TRUE AS search,
|
||||
TRUE AS border,
|
||||
TRUE AS hover,
|
||||
TRUE AS striped_columns,
|
||||
TRUE AS striped_rows,
|
||||
TRUE AS small,
|
||||
'table_class' AS class,
|
||||
'table_id' AS id;
|
||||
|
||||
SELECT * FROM pragma_database_list() ORDER BY seq;
|
||||
|
||||
-- =============================================================================
|
||||
-- DEBUG
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
sqlpage.run_sql('footer_debug_post-get-set.sql') AS properties;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Authentication Fence
|
||||
|
||||
set username = (
|
||||
SELECT username
|
||||
FROM sessions
|
||||
WHERE sqlpage.cookie('session_token') = id
|
||||
AND created_at > datetime('now', '-1 day') -- require the user to log in again after 1 day
|
||||
);
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
'/' AS link -- redirect to the front page if the user is logged in
|
||||
WHERE $username IS NOT NULL;
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'shell' AS component,
|
||||
'CRUD with Authentication' AS title,
|
||||
'database' AS icon;
|
||||
|
||||
-- =============================================================================
|
||||
-- ================================= Login =====================================
|
||||
-- =============================================================================
|
||||
|
||||
SELECT
|
||||
'form' AS component,
|
||||
'Login' AS title,
|
||||
'create_session.sql' || ifnull('?path=' || $path, '') AS action;
|
||||
|
||||
-- Define form fields
|
||||
|
||||
SELECT
|
||||
column1 AS name, column2 AS label,
|
||||
column3 AS type, column4 AS required
|
||||
FROM (VALUES
|
||||
('username', 'Username', 'text', TRUE),
|
||||
('password', 'Password', 'password', TRUE)
|
||||
);
|
||||
|
||||
-- Show alert on failed authentication.
|
||||
|
||||
SELECT
|
||||
'alert' AS component,
|
||||
'danger' AS color,
|
||||
'You are not logged in' AS title,
|
||||
'Sorry, we could not log you in. Please try again.' AS description
|
||||
WHERE $error IS NOT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
SELECT
|
||||
'cookie' AS component,
|
||||
'session_token' AS name,
|
||||
TRUE AS remove;
|
||||
|
||||
SELECT
|
||||
'redirect' AS component,
|
||||
ifnull($path, '/login.sql') AS link -- redirect to the login page after the user logs out
|
||||
@@ -0,0 +1,22 @@
|
||||
select
|
||||
'shell' as component,
|
||||
'SQLPage' as title,
|
||||
'database' as icon,
|
||||
'/' as link,
|
||||
'Top' as menu_item,
|
||||
'{"title":"About","submenu":[{"link":"/safety.sql","title":"Security"},{"link":"/performance.sql","title":"Performance"},{"link":"//github.com/sqlpage/SQLPage/blob/main/LICENSE.txt","title":"License"},{"link":"/blog.sql","title":"Articles"}]}' as menu_item,
|
||||
NULL as menu_item,
|
||||
'{"title":"Examples","submenu":[{"link":"/examples/tabs/","title":"Tabs"},{"link":"/examples/layouts.sql","title":"Layouts"},{"link":"/examples/multistep-form","title":"Forms"},{"link":"/examples/handle_picture_upload.sql","title":"File uploads"},{"link":"/examples/hash_password.sql","title":"Password protection"},{"link":"//github.com/sqlpage/SQLPage/blob/main/examples/","title":"All examples & demos"}]}' as menu_item,
|
||||
'{"title":"z", "icon": "settings"}' as menu_item,
|
||||
'{"title":"", "icon": ""}' as menu_item,
|
||||
'{"title":"Community","submenu":[{"link":"blog.sql","title":"Blog"},{"link":"//github.com/sqlpage/SQLPage/issues","title":"Report a bug"},{"link":"//github.com/sqlpage/SQLPage/discussions","title":"Discussions"},{"link":"//github.com/sqlpage/SQLPage","title":"Github"}]}' as menu_item,
|
||||
NULL as menu_item,
|
||||
'{"title":"Documentation","submenu":[{"link":"/your-first-sql-website","title":"Getting started"},{"link":"/components.sql","title":"All Components"},{"link":"/functions.sql","title":"SQLPage Functions"},{"link":"/custom_components.sql","title":"Custom Components"},{"link":"//github.com/sqlpage/SQLPage/blob/main/configuration.md#configuring-sqlpage","title":"Configuration"}]}' as menu_item,
|
||||
'boxed' as layout,
|
||||
'en-US' as language,
|
||||
'Documentation for the SQLPage low-code web application framework.' as description,
|
||||
'Poppins' as font,
|
||||
'https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js' as javascript,
|
||||
'https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js' as javascript,
|
||||
'/prism-tabler-theme.css' as css,
|
||||
'Official [SQLPage](https://sql-page.com) documentation' as footer;
|
||||
@@ -0,0 +1,271 @@
|
||||
set _get_vars = (
|
||||
SELECT
|
||||
json_group_object(
|
||||
"key",
|
||||
iif(CAST(CAST(value AS NUMERIC) AS TEXT) = value, CAST(value AS NUMERIC), value)
|
||||
) AS get_var
|
||||
FROM
|
||||
json_each(sqlpage.variables('GET'))
|
||||
WHERE NOT "key" like '\_%' ESCAPE '\'
|
||||
);
|
||||
|
||||
|
||||
set _locale_code = $lang; -- 'en', 'ru', 'de', 'fr', 'zh-cn'
|
||||
set _theme = 'fancy'; --$theme; -- 'default', 'fancy'
|
||||
set _hide_language_names = $hide_language_names; -- 0, 1 (BOOLEAN)
|
||||
set _authenticated = $authenticated; -- 0, 1 (BOOLEAN)
|
||||
|
||||
-- =============================================================================
|
||||
-- =============================================================================
|
||||
|
||||
WITH
|
||||
|
||||
-- test_values(_locale_code, _theme, _hide_language_names, _authenticated ) AS (VALUES
|
||||
-- ( 'en', 'fancy', TRUE, FALSE)
|
||||
-- ( NULL, NULL, NULL, NULL)
|
||||
-- ( 'fr', 'default', TRUE, TRUE)
|
||||
-- ),
|
||||
|
||||
-- Replaces values with appropriate variables
|
||||
-- ifnull guuards from invalid JSON errors
|
||||
|
||||
config_user AS (
|
||||
SELECT
|
||||
lower(ifnull($_locale_code, '_')) AS locale_code,
|
||||
lower(ifnull($_theme, '_')) AS theme,
|
||||
CAST($_hide_language_names AS INTEGER) AS hide_language_names,
|
||||
CAST(ifnull($_authenticated, FALSE) AS INTEGER) AS authenticated
|
||||
-- FROM test_values
|
||||
),
|
||||
|
||||
-- Inputs data guards
|
||||
|
||||
config_guards AS (
|
||||
SELECT
|
||||
(
|
||||
SELECT iif(contents ->> ('$.' || locale_code) IS NULL, NULL, locale_code)
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'locales/locales.json'
|
||||
) AS locale_code,
|
||||
(
|
||||
SELECT iif(contents ->> ('$.' || theme) IS NULL, NULL, theme)
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'themes/themes.json'
|
||||
) AS theme,
|
||||
hide_language_names,
|
||||
authenticated
|
||||
FROM config_user
|
||||
),
|
||||
|
||||
-- Retrieves locale and theme JSON data
|
||||
|
||||
config AS (
|
||||
SELECT
|
||||
iif(locale_code IS NULL, NULL, (
|
||||
SELECT contents ->> '$.menu'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'locales/' || locale_code || '/locale.json'
|
||||
)) AS locale,
|
||||
iif(theme IS NULL, NULL, (
|
||||
SELECT contents ->> '$.menu'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'themes/' || theme || '/theme.json'
|
||||
)) AS theme,
|
||||
(
|
||||
SELECT contents ->> '$.menu'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'themes/default/theme.json'
|
||||
) AS theme_default,
|
||||
(
|
||||
SELECT contents ->> '$.meta.label'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'locales/' || locale_code || '/locale.json'
|
||||
) AS locale_label,
|
||||
hide_language_names,
|
||||
authenticated
|
||||
FROM config_guards
|
||||
),
|
||||
|
||||
-- Prepares language items.
|
||||
-- This is a dynamically generated menu item.
|
||||
|
||||
locale_codes AS (
|
||||
SELECT "key" AS code, value AS position
|
||||
FROM sqlpage_files, json_each(contents)
|
||||
WHERE sqlpage_files.path = 'locales/locales.json'
|
||||
),
|
||||
languages AS (
|
||||
SELECT
|
||||
position,
|
||||
code,
|
||||
contents ->> '$.meta.label' AS label
|
||||
FROM sqlpage_files, locale_codes
|
||||
WHERE path like 'locales/%/locale.json'
|
||||
AND contents ->> '$.meta.code' = code COLLATE NOCASE
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- Prepares a list of top menu items with default icons.
|
||||
-- The language menu includes the "global/neutral/undefinded" icon.
|
||||
|
||||
top_menus_global AS (
|
||||
SELECT
|
||||
position,
|
||||
label,
|
||||
|
||||
-- Hide top menu with submenu if a particular filter is included in
|
||||
-- state_filter and its current value does match the specified value.
|
||||
--
|
||||
-- Note that the "class" attribute is set to two classes:
|
||||
-- 'menu_' || lower(label) and the same with '_slim' suffix.
|
||||
-- These classes are applied to respective submenus for css-based fine-tuning.
|
||||
|
||||
CASE
|
||||
WHEN (state_filter ->> '$.authenticated') IS NULL
|
||||
OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN
|
||||
json_object(
|
||||
'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)),
|
||||
iif(theme IS NULL, 'icon', 'image'),
|
||||
iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))),
|
||||
'class',
|
||||
-- Handles special cases: only sets the '_slim' class on the 'Language'
|
||||
-- menu when language names (labels) are hidden. Only set the base class
|
||||
-- for English localization (the extra whitespaces are painfull...)
|
||||
iif(ifnull(locale_label, 'English') IN ('English', 'Chinese'),
|
||||
' menu_' || lower(label), '') ||
|
||||
iif(label = 'Language' AND hide_language_names IS NOT TRUE, '',
|
||||
' menu_' || lower(label) || '_slim'
|
||||
)
|
||||
)
|
||||
ELSE
|
||||
NULL
|
||||
END AS top_item
|
||||
FROM menus, config
|
||||
WHERE parent_label IS NULL
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- The sole purpose of this separate modification is to set the icon
|
||||
-- of the top language menu to reflect the current locale
|
||||
|
||||
top_menus AS (
|
||||
SELECT
|
||||
position,
|
||||
label,
|
||||
iif(label <> 'Language' OR locale IS NULL OR top_item IS NULL,
|
||||
top_item,
|
||||
json_set(
|
||||
top_item,
|
||||
'$.image',
|
||||
'/' || (theme ->> ('$.' || locale_label))
|
||||
)
|
||||
) AS top_item
|
||||
FROM top_menus_global, config
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- Prepares a list of submenu lines.
|
||||
|
||||
menu_lines AS (
|
||||
SELECT
|
||||
parent_label,
|
||||
position,
|
||||
|
||||
-- Hides menu line if a particular filter is included in state_filter
|
||||
-- and its current value does match the specified value
|
||||
|
||||
CASE
|
||||
WHEN (state_filter ->> '$.authenticated') IS NULL
|
||||
OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN
|
||||
json_object(
|
||||
'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)),
|
||||
iif(theme IS NULL, 'icon', 'image'),
|
||||
iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))),
|
||||
'link', link
|
||||
)
|
||||
END AS menu_line
|
||||
FROM menus, config
|
||||
WHERE parent_label IS NOT NULL
|
||||
|
||||
-- Generates and appends the Language submenu lines
|
||||
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Language' AS parent_label,
|
||||
position,
|
||||
json_object(
|
||||
'title',
|
||||
iif(hide_language_names IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)),
|
||||
'image', '/' || (iif(theme IS NULL, theme_default, theme) ->> ('$.' || label)),
|
||||
'link', '?lang=' || code
|
||||
) AS menu_line
|
||||
FROM languages, config
|
||||
ORDER BY parent_label, position
|
||||
),
|
||||
|
||||
-- Groups menu lines into submenus
|
||||
|
||||
menu_lists AS (
|
||||
SELECT
|
||||
parent_label,
|
||||
json_group_array(
|
||||
json(menu_line) ORDER BY position
|
||||
) AS menu_list
|
||||
FROM menu_lines
|
||||
GROUP BY parent_label
|
||||
),
|
||||
|
||||
-- Combines submenus with corresponding top menu lines yielding complete menu_item objects
|
||||
|
||||
menu_sets AS (
|
||||
SELECT
|
||||
position,
|
||||
label,
|
||||
json_set(json(top_item), '$.submenu', json(menu_list)) AS menu_set
|
||||
FROM top_menus, menu_lists
|
||||
WHERE top_menus.label = menu_lists.parent_label
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- Prepares final array of menu_item objects to be used with the "dynamic" component
|
||||
|
||||
menu AS (
|
||||
SELECT
|
||||
json_group_array(json(menu_set) ORDER BY position) AS menu
|
||||
FROM menu_sets
|
||||
),
|
||||
|
||||
-- shell_dynamic_static is included for debugging purposes. Call
|
||||
-- it to generate "static" SQL for inclusion in an SQLPage script.
|
||||
|
||||
shell_dynamic_static AS (
|
||||
SELECT
|
||||
'SELECT' || x'0A' || ' ''dynamic'' AS component,' || x'0A' ||
|
||||
quote(json_pretty(json_object(
|
||||
'component', 'shell',
|
||||
'title', 'SQLPage',
|
||||
'icon', 'database',
|
||||
'link', '/',
|
||||
'css', '/css/style.css',
|
||||
'menu_item', json(menu)
|
||||
))) || ' AS properties' || x'0A0A' AS properties
|
||||
FROM menu
|
||||
),
|
||||
|
||||
-- Call shell_dynamic if this script is processed directly by SQLPage.
|
||||
-- After copy-pasting adjust the input controls in the first CTE.
|
||||
|
||||
shell_dynamic AS (
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
json_object(
|
||||
'component', 'shell',
|
||||
'title', 'SQLPage',
|
||||
'icon', 'database',
|
||||
'link', '/',
|
||||
'css', '/css/style.css',
|
||||
'menu_item', json(menu)
|
||||
) AS properties
|
||||
FROM menu
|
||||
)
|
||||
SELECT * FROM shell_dynamic;
|
||||
@@ -0,0 +1,258 @@
|
||||
-- set _locale_code = $lang; -- 'en', 'ru', 'de', 'fr', 'zh-cn'
|
||||
-- set _theme = $theme; -- 'default', 'fancy'
|
||||
-- set _hide_language_names = $hide_language_names; -- 0, 1 (BOOLEAN)
|
||||
-- set _authenticated = $authenticated; -- 0, 1 (BOOLEAN)
|
||||
|
||||
-- =============================================================================
|
||||
-- =============================================================================
|
||||
|
||||
WITH
|
||||
|
||||
test_values(_locale_code, _theme, _hide_language_names, _authenticated ) AS (VALUES
|
||||
-- ( 'en', 'fancy', TRUE, FALSE)
|
||||
-- ( NULL, NULL, NULL, NULL)
|
||||
( 'fr', 'default', TRUE, TRUE)
|
||||
),
|
||||
|
||||
-- Replaces values with appropriate variables
|
||||
-- ifnull guuards from invalid JSON errors
|
||||
|
||||
config_user AS (
|
||||
SELECT
|
||||
lower(ifnull(_locale_code, '_')) AS locale_code,
|
||||
lower(ifnull(_theme, '_')) AS theme,
|
||||
CAST(_hide_language_names AS INTEGER) AS hide_language_names,
|
||||
CAST(ifnull(_authenticated, FALSE) AS INTEGER) AS authenticated
|
||||
FROM test_values
|
||||
),
|
||||
|
||||
-- Inputs data guards
|
||||
|
||||
config_guards AS (
|
||||
SELECT
|
||||
(
|
||||
SELECT iif(contents ->> ('$.' || locale_code) IS NULL, NULL, locale_code)
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'locales/locales.json'
|
||||
) AS locale_code,
|
||||
(
|
||||
SELECT iif(contents ->> ('$.' || theme) IS NULL, NULL, theme)
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'themes/themes.json'
|
||||
) AS theme,
|
||||
hide_language_names,
|
||||
authenticated
|
||||
FROM config_user
|
||||
),
|
||||
|
||||
-- Retrieves locale and theme JSON data
|
||||
|
||||
config AS (
|
||||
SELECT
|
||||
iif(locale_code IS NULL, NULL, (
|
||||
SELECT contents ->> '$.menu'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'locales/' || locale_code || '/locale.json'
|
||||
)) AS locale,
|
||||
iif(theme IS NULL, NULL, (
|
||||
SELECT contents ->> '$.menu'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'themes/' || theme || '/theme.json'
|
||||
)) AS theme,
|
||||
(
|
||||
SELECT contents ->> '$.menu'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'themes/default/theme.json'
|
||||
) AS theme_default,
|
||||
(
|
||||
SELECT contents ->> '$.meta.label'
|
||||
FROM sqlpage_files
|
||||
WHERE path = 'locales/' || locale_code || '/locale.json'
|
||||
) AS locale_label,
|
||||
hide_language_names,
|
||||
authenticated
|
||||
FROM config_guards
|
||||
),
|
||||
|
||||
-- Prepares language items.
|
||||
-- This is a dynamically generated menu item.
|
||||
|
||||
locale_codes AS (
|
||||
SELECT "key" AS code, value AS position
|
||||
FROM sqlpage_files, json_each(contents)
|
||||
WHERE sqlpage_files.path = 'locales/locales.json'
|
||||
),
|
||||
languages AS (
|
||||
SELECT
|
||||
position,
|
||||
code,
|
||||
contents ->> '$.meta.label' AS label
|
||||
FROM sqlpage_files, locale_codes
|
||||
WHERE path like 'locales/%/locale.json'
|
||||
AND contents ->> '$.meta.code' = code COLLATE NOCASE
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- Prepares a list of top menu items with default icons.
|
||||
-- The language menu includes the "global/neutral/undefinded" icon.
|
||||
|
||||
top_menus_global AS (
|
||||
SELECT
|
||||
position,
|
||||
label,
|
||||
|
||||
-- Hide top menu with submenu if a particular filter is included in
|
||||
-- state_filter and its current value does match the specified value.
|
||||
--
|
||||
-- Note that the "class" attribute is set to two classes:
|
||||
-- 'menu_' || lower(label) and the same with '_slim' suffix.
|
||||
-- These classes are applied to respective submenus for css-based fine-tuning.
|
||||
|
||||
CASE
|
||||
WHEN (state_filter ->> '$.authenticated') IS NULL
|
||||
OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN
|
||||
json_object(
|
||||
'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)),
|
||||
iif(theme IS NULL, 'icon', 'image'),
|
||||
iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))),
|
||||
'class',
|
||||
-- Handles special cases: only sets the '_slim' class on the 'Language'
|
||||
-- menu when language names (labels) are hidden. Only set the base class
|
||||
-- for English localization (the extra whitespaces are painfull...)
|
||||
iif(locale_label = 'English', ' menu_' || lower(label), '') ||
|
||||
iif(label = 'Language' AND hide_language_names IS NOT TRUE, '',
|
||||
' menu_' || lower(label) || '_slim'
|
||||
)
|
||||
)
|
||||
ELSE
|
||||
NULL
|
||||
END AS top_item
|
||||
FROM menus, config
|
||||
WHERE parent_label IS NULL
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- The sole purpose of this separate modification is to set the icon
|
||||
-- of the top language menu to reflect the current locale
|
||||
|
||||
top_menus AS (
|
||||
SELECT
|
||||
position,
|
||||
label,
|
||||
iif(label <> 'Language' OR locale IS NULL OR top_item IS NULL,
|
||||
top_item,
|
||||
json_set(
|
||||
top_item,
|
||||
'$.image',
|
||||
'/' || (theme ->> ('$.' || locale_label))
|
||||
)
|
||||
) AS top_item
|
||||
FROM top_menus_global, config
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- Prepares a list of submenu lines.
|
||||
|
||||
menu_lines AS (
|
||||
SELECT
|
||||
parent_label,
|
||||
position,
|
||||
|
||||
-- Hides menu line if a particular filter is included in state_filter
|
||||
-- and its current value does match the specified value
|
||||
|
||||
CASE
|
||||
WHEN (state_filter ->> '$.authenticated') IS NULL
|
||||
OR state_filter ->> '$.authenticated' = ifnull(authenticated, FALSE) THEN
|
||||
json_object(
|
||||
'title', iif(icon_only IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)),
|
||||
iif(theme IS NULL, 'icon', 'image'),
|
||||
iif(theme IS NULL, tabler_icon, '/' || (theme ->> ('$.' || label))),
|
||||
'link', link
|
||||
)
|
||||
END AS menu_line
|
||||
FROM menus, config
|
||||
WHERE parent_label IS NOT NULL
|
||||
|
||||
-- Generates and appends the Language submenu lines
|
||||
|
||||
UNION ALL
|
||||
SELECT
|
||||
'Language' AS parent_label,
|
||||
position,
|
||||
json_object(
|
||||
'title',
|
||||
iif(hide_language_names IS TRUE, NULL, ifnull(locale ->> ('$.' || label), label)),
|
||||
'image', '/' || (iif(theme IS NULL, theme_default, theme) ->> ('$.' || label)),
|
||||
'link', '/locales/locale.sql?lang=' || code
|
||||
) AS menu_line
|
||||
FROM languages, config
|
||||
ORDER BY parent_label, position
|
||||
),
|
||||
|
||||
-- Groups menu lines into submenus
|
||||
|
||||
menu_lists AS (
|
||||
SELECT
|
||||
parent_label,
|
||||
json_group_array(
|
||||
json(menu_line) ORDER BY position
|
||||
) AS menu_list
|
||||
FROM menu_lines
|
||||
GROUP BY parent_label
|
||||
),
|
||||
|
||||
-- Combines submenus with corresponding top menu lines yielding complete menu_item objects
|
||||
|
||||
menu_sets AS (
|
||||
SELECT
|
||||
position,
|
||||
label,
|
||||
json_set(json(top_item), '$.submenu', json(menu_list)) AS menu_set
|
||||
FROM top_menus, menu_lists
|
||||
WHERE top_menus.label = menu_lists.parent_label
|
||||
ORDER BY position
|
||||
),
|
||||
|
||||
-- Prepares final array of menu_item objects to be used with the "dynamic" component
|
||||
|
||||
menu AS (
|
||||
SELECT
|
||||
json_group_array(json(menu_set) ORDER BY position) AS menu
|
||||
FROM menu_sets
|
||||
),
|
||||
|
||||
-- shell_dynamic_static is included for debugging purposes. Call
|
||||
-- it to generate "static" SQL for inclusion in an SQLPage script.
|
||||
|
||||
shell_dynamic_static AS (
|
||||
SELECT
|
||||
'SELECT' || x'0A' || ' ''dynamic'' AS component,' || x'0A' ||
|
||||
quote(json_pretty(json_object(
|
||||
'component', 'shell',
|
||||
'title', 'SQLPage',
|
||||
'icon', 'database',
|
||||
'link', '/',
|
||||
'css', '/css/style.css',
|
||||
'menu_item', json(menu)
|
||||
))) || ' AS properties' || x'0A0A' AS properties
|
||||
FROM menu
|
||||
),
|
||||
|
||||
-- Call shell_dynamic if this script is processed directly by SQLPage.
|
||||
-- After copy-pasting adjust the input controls in the first CTE.
|
||||
|
||||
shell_dynamic AS (
|
||||
SELECT
|
||||
'dynamic' AS component,
|
||||
json_object(
|
||||
'component', 'shell',
|
||||
'title', 'SQLPage',
|
||||
'icon', 'database',
|
||||
'link', '/',
|
||||
'css', '/css/style.css',
|
||||
'menu_item', json(menu)
|
||||
) AS properties
|
||||
FROM menu
|
||||
)
|
||||
SELECT * FROM shell_dynamic_static;
|
||||
@@ -0,0 +1,116 @@
|
||||
select 'dynamic' as component,
|
||||
'[
|
||||
{
|
||||
"component": "shell",
|
||||
"title": "SQLPage",
|
||||
"icon": "database",
|
||||
"link": "/",
|
||||
"menu_item": [
|
||||
{
|
||||
"icon": "settings",
|
||||
"title": "Z",
|
||||
"button": true,
|
||||
"shape": "pill",
|
||||
"narrow": true,
|
||||
"outline": "warning",
|
||||
"submenu": [
|
||||
{
|
||||
"link": "/safety.sql",
|
||||
"icon": "user",
|
||||
"button": true,
|
||||
"shape": "pill",
|
||||
"size": "sm",
|
||||
"tooltip": "User",
|
||||
"color": "yellow"
|
||||
},
|
||||
{},
|
||||
{
|
||||
"link": "/performance.sql",
|
||||
"icon": "logout",
|
||||
"tooltip": "Logout",
|
||||
"button": true,
|
||||
"shape": "pill",
|
||||
"size": "sm",
|
||||
"outline": "warning"
|
||||
|
||||
},
|
||||
{
|
||||
"link": "/performance.sql",
|
||||
"icon": "",
|
||||
"tooltip": "Logout",
|
||||
"button": true,
|
||||
"shape": "pill",
|
||||
"size": "sm",
|
||||
"outline": "warning"
|
||||
|
||||
}
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"icon": "database",
|
||||
"title": "Dummy",
|
||||
"button": true,
|
||||
"shape": "pill",
|
||||
"narrow": true,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"icon": "",
|
||||
"title": "",
|
||||
"button": true,
|
||||
"shape": "pill",
|
||||
"narrow": true,
|
||||
"color": "blue"
|
||||
},
|
||||
{
|
||||
"title": "Examples",
|
||||
"icon": "trash",
|
||||
"submenu": [
|
||||
{
|
||||
"link": "/examples/tabs/",
|
||||
"icon": "device-floppy",
|
||||
"title": "Tabs"
|
||||
},
|
||||
{
|
||||
"link": "/examples/layouts.sql",
|
||||
"button": true,
|
||||
"tooltip": "Layouts",
|
||||
"title": "Layouts",
|
||||
"color": "primary"
|
||||
},
|
||||
{
|
||||
"link": "/examples/multistep-form",
|
||||
"title": "Forms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Community",
|
||||
"submenu": [
|
||||
{
|
||||
"link": "blog.sql",
|
||||
"title": "Blog"
|
||||
},
|
||||
{
|
||||
"link": "//github.com/sqlpage/SQLPage/issues",
|
||||
"title": "Report a bug"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]' AS properties;
|
||||
|
||||
SELECT
|
||||
'button' AS component,
|
||||
'pill' AS shape,
|
||||
'' AS size,
|
||||
'center' AS justify;
|
||||
SELECT
|
||||
'' AS title,
|
||||
'browse_rec' AS id,
|
||||
'green' AS outline,
|
||||
TRUE AS narrow,
|
||||
'#' AS link,
|
||||
'/icons/earth-icon.svg' AS img;
|
||||
@@ -0,0 +1,19 @@
|
||||
# Using PostGIS to build a geographic data application
|
||||
|
||||
## Introduction
|
||||
|
||||
This is a small application that uses [PostGIS](https://postgis.net/)
|
||||
to save data associated with geographic coordinates.
|
||||
|
||||
If you are using a SQLite database, see [this other example instead](../make%20a%20geographic%20data%20application%20using%20sqlite%20extensions/),
|
||||
which implements the same application using the `spatialite` extension for SQLite.
|
||||
|
||||
### Installation
|
||||
|
||||
You need to install the `postgis` extension for postgres. Follow [the official instructions](https://postgis.net/documentation/getting_started/).
|
||||
|
||||
Then you can instruct SQLPage to connect to your database by editing the [`sqlpage/sqlpage.json`](./sqlpage/sqlpage.json) file.
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
@@ -0,0 +1,11 @@
|
||||
INSERT INTO spatial_data (title, geom, description)
|
||||
VALUES (
|
||||
:Title,
|
||||
ST_MakePoint(
|
||||
CAST(:Longitude AS REAL),
|
||||
CAST(:Latitude AS REAL
|
||||
), 4326),
|
||||
:Text
|
||||
) RETURNING
|
||||
'redirect' AS component,
|
||||
'index.sql' AS link;
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- ./sqlpage:/etc/sqlpage
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres:postgres@db/postgres
|
||||
db:
|
||||
image: postgis/postgis:latest
|
||||
platform: linux/amd64
|
||||
environment:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
@@ -0,0 +1,24 @@
|
||||
SELECT 'shell' as component,
|
||||
'Point edition' AS title,
|
||||
'/' as link,
|
||||
'book' as icon;
|
||||
|
||||
UPDATE spatial_data
|
||||
SET description = :Text
|
||||
WHERE id = $id::int AND :Text IS NOT NULL;
|
||||
|
||||
SELECT 'form' AS component, title
|
||||
FROM spatial_data WHERE id = $id::int;
|
||||
|
||||
SELECT
|
||||
'Text' AS name,
|
||||
'Description for the point: ' || title AS description,
|
||||
'textarea' AS type,
|
||||
description AS value
|
||||
FROM spatial_data
|
||||
WHERE id = $id::int;
|
||||
|
||||
SELECT 'text' as component,
|
||||
description as contents_md
|
||||
FROM spatial_data
|
||||
WHERE id = $id::int;
|
||||
@@ -0,0 +1,38 @@
|
||||
SELECT 'shell' as component,
|
||||
'Map' AS title,
|
||||
'/' as link,
|
||||
'book' as icon;
|
||||
|
||||
SELECT 'map' AS component,
|
||||
ST_Y(geom) AS latitude,
|
||||
ST_X(geom) AS longitude
|
||||
FROM spatial_data
|
||||
WHERE id = $id::int;
|
||||
|
||||
SELECT 'map' as component,
|
||||
'Points of interest' as title,
|
||||
2 as zoom,
|
||||
700 as height;
|
||||
SELECT title,
|
||||
ST_Y(geom) AS latitude,
|
||||
ST_X(geom) AS longitude,
|
||||
format('[View](point.sql?id=%s) [Edit](edition_form.sql?id=%s)', id, id) as description_md
|
||||
FROM spatial_data;
|
||||
|
||||
|
||||
SELECT 'list' as component,
|
||||
'My points' as title;
|
||||
SELECT title,
|
||||
'point.sql?id=' || id as link,
|
||||
'red' as color,
|
||||
'world-pin' as icon
|
||||
FROM spatial_data;
|
||||
|
||||
SELECT 'form' AS component,
|
||||
'Add a point' AS title,
|
||||
'add_point.sql' AS action;
|
||||
|
||||
SELECT 'Title' AS name;
|
||||
SELECT 'Latitude' AS name, 'number' AS type, 0.00000001 AS step;
|
||||
SELECT 'Longitude' AS name, 'number' AS type, 0.00000001 AS step;
|
||||
SELECT 'Text' AS name, 'textarea' AS type;
|
||||
@@ -0,0 +1,58 @@
|
||||
SELECT 'shell' as component,
|
||||
title,
|
||||
'/' as link,
|
||||
'index' as menu_item,
|
||||
'book' as icon
|
||||
FROM spatial_data
|
||||
WHERE id = $id::int;
|
||||
|
||||
SELECT 'datagrid' as component, title FROM spatial_data WHERE id = $id::int;
|
||||
|
||||
SELECT 'Latitude' as title,
|
||||
ST_Y(geom) as description,
|
||||
'purple' as color,
|
||||
'world-latitude' as icon
|
||||
FROM spatial_data WHERE id = $id::int;
|
||||
|
||||
SELECT 'Longitude' as title,
|
||||
ST_X(geom) as description,
|
||||
'purple' as color,
|
||||
'world-longitude' as icon
|
||||
FROM spatial_data WHERE id = $id::int;
|
||||
|
||||
SELECT 'Created at' as title,
|
||||
created_at as description,
|
||||
'calendar' as icon,
|
||||
'Date and time of creation' as footer
|
||||
FROM spatial_data WHERE id = $id::int;
|
||||
|
||||
SELECT 'Label' as title,
|
||||
title as description,
|
||||
'geo:' || ST_Y(geom) || ',' || ST_X(geom) || '?z=16' AS link,
|
||||
'blue' as color,
|
||||
'world' as icon,
|
||||
'User-generated point name' as footer
|
||||
FROM spatial_data
|
||||
WHERE id = $id::int;
|
||||
|
||||
SELECT 'text' as component,
|
||||
description ||
|
||||
format(E'\n\n [Edit description](edition_form.sql?id=%s)', id)
|
||||
as contents_md
|
||||
FROM spatial_data
|
||||
WHERE id = $id::int;
|
||||
|
||||
SELECT 'list' as component, 'Closest points' as title;
|
||||
SELECT to_label as title,
|
||||
ROUND(distance::decimal, 3) || ' km' as description,
|
||||
'point.sql?id=' || to_id as link,
|
||||
'red' as color,
|
||||
'world-pin' as icon
|
||||
FROM distances
|
||||
WHERE from_id = $id::int
|
||||
ORDER BY distance
|
||||
LIMIT 5;
|
||||
|
||||
SELECT 'map' AS component,
|
||||
ST_Y(geom) AS latitude, ST_X(geom) AS longitude
|
||||
FROM spatial_data WHERE id = $id::int;
|
||||
|
After Width: | Height: | Size: 846 KiB |
@@ -0,0 +1,24 @@
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- Create a table with a postgis geometry column
|
||||
CREATE TABLE IF NOT EXISTS spatial_data (
|
||||
id serial primary key NOT NULL,
|
||||
title text NOT NULL,
|
||||
geom geometry NULL,
|
||||
description text NOT NULL,
|
||||
created_at timestamp without time zone NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
CREATE OR REPLACE VIEW distances AS
|
||||
SELECT from_point.id AS from_id,
|
||||
from_point.title AS from_label,
|
||||
to_point.id AS to_id,
|
||||
to_point.title AS to_label,
|
||||
ST_Distance(
|
||||
from_point.geom,
|
||||
to_point.geom,
|
||||
TRUE
|
||||
) AS distance
|
||||
FROM spatial_data AS from_point, spatial_data AS to_point
|
||||
WHERE from_point.id != to_point.id;
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"database_url": "postgres://my_username:my_password@localhost:5432/my_geographic_database"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Points of interest"
|
||||
body contains "Add a point"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/add_point.sql
|
||||
[FormParams]
|
||||
Title: Hurl Paris
|
||||
Latitude: 48.8566
|
||||
Longitude: 2.3522
|
||||
Text: First Hurl point
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "index.sql"
|
||||
|
||||
POST http://localhost:8080/add_point.sql
|
||||
[FormParams]
|
||||
Title: Hurl Lyon
|
||||
Latitude: 45.764
|
||||
Longitude: 4.8357
|
||||
Text: Second Hurl point
|
||||
HTTP 302
|
||||
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Captures]
|
||||
paris_id: xpath "substring-after(string(//a[normalize-space(.)='Hurl Paris']/@href), 'id=')"
|
||||
[Asserts]
|
||||
body contains "Hurl Paris"
|
||||
body contains "Hurl Lyon"
|
||||
body htmlUnescape contains "point.sql?id={{paris_id}}"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/point.sql?id={{paris_id}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "48.8566"
|
||||
body contains "2.3522"
|
||||
body contains "First Hurl point"
|
||||
body contains "Closest points"
|
||||
body contains "Hurl Lyon"
|
||||
body htmlUnescape contains "edition_form.sql?id={{paris_id}}"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/edition_form.sql?id={{paris_id}}
|
||||
[FormParams]
|
||||
Text: Updated Hurl point
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Updated Hurl point"
|
||||
body not contains "An error occurred"
|
||||
@@ -0,0 +1,5 @@
|
||||
# Website editor
|
||||
|
||||
SQLPage supports rendering `.sql` files that are stored directly inside the database, in a table called `sqlpage_files`.
|
||||
|
||||
This application allows you to edit these files directly from your browser, for an easy in-browser data app authoring experience.
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main # main is cutting edge, use sqlpage/SQLPage:latest for the latest stable version
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./website:/var/www
|
||||
- ./sqlpage:/etc/sqlpage
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: postgres://root:secret@db/sqlpage
|
||||
db: # The DB environment variable can be set to "mariadb" or "postgres" to test the code with different databases
|
||||
image: postgres
|
||||
environment:
|
||||
POSTGRES_USER: root
|
||||
POSTGRES_DB: sqlpage
|
||||
POSTGRES_PASSWORD: secret
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Given a table name as text, return the contents of the table as a set of json objects
|
||||
-- Safely escapes the table name to prevent SQL injection.
|
||||
-- Accepts only normal tables, not postgres system tables.
|
||||
CREATE OR REPLACE FUNCTION table_contents (table_name text)
|
||||
RETURNS SETOF json AS $$
|
||||
BEGIN
|
||||
RETURN QUERY EXECUTE
|
||||
format('SELECT row_to_json(%I) FROM %I', table_name, table_name);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE
|
||||
sqlpage_files (
|
||||
path VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
contents BYTEA NOT NULL,
|
||||
last_modified TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- automatically update last_modified timestamp
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_last_modified_sqlpage_files()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.last_modified = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_last_modified BEFORE
|
||||
UPDATE ON sqlpage_files FOR EACH ROW
|
||||
EXECUTE PROCEDURE update_last_modified_sqlpage_files();
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"content_security_policy": "script-src blob: 'self' https://cdn.jsdelivr.net;"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
header "Content-Type" contains "text/html"
|
||||
body contains "Website files"
|
||||
body contains "Create new file"
|
||||
body contains "Database tables"
|
||||
body contains "sqlpage_files"
|
||||
body htmlUnescape contains "view_table.sql?table%5Fname=sqlpage%5Ffiles"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/edit.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "New page"
|
||||
body contains "insert_file.sql"
|
||||
body contains "code-editor"
|
||||
body not contains "An error occurred"
|
||||
|
||||
POST http://localhost:8080/insert_file.sql
|
||||
[FormParams]
|
||||
path: hurl_test.sql
|
||||
contents: SELECT 'text' as component, 'Hello from Hurl' as contents;
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "index.sql?inserted=hurl%5Ftest%2Esql"
|
||||
|
||||
GET http://localhost:8080/hurl_test.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Hello from Hurl"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "hurl_test.sql"
|
||||
body htmlUnescape contains "edit.sql?path=hurl%5Ftest%2Esql"
|
||||
body htmlUnescape contains "delete.sql?path=hurl%5Ftest%2Esql"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/view_table.sql?table_name=sqlpage_files
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "sqlpage_files"
|
||||
body contains "hurl_test.sql"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/delete.sql?path=hurl_test.sql
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Delete hurl_test.sql ?"
|
||||
body htmlUnescape contains "delete.sql?path=hurl%5Ftest%2Esql&confirm=yes"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/delete.sql?path=hurl_test.sql&confirm=yes
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" == "index.sql?deleted=hurl%5Ftest%2Esql"
|
||||
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body not contains "hurl_test.sql"
|
||||
body not contains "An error occurred"
|
||||
@@ -0,0 +1,15 @@
|
||||
delete from sqlpage_files
|
||||
where path = $path and $confirm = 'yes'
|
||||
returning
|
||||
'redirect' as component,
|
||||
sqlpage.link(
|
||||
'index.sql',
|
||||
json_build_object('deleted', $path)
|
||||
) as link;
|
||||
|
||||
select 'alert' as component,
|
||||
'Delete ' || $path || ' ?' as title,
|
||||
'Are you sure you want to delete ' || $path || '?' as description,
|
||||
'warning' as color,
|
||||
sqlpage.link('delete.sql', json_build_object('path', $path, 'confirm', 'yes')) as link,
|
||||
'Delete' as link_text;
|
||||
@@ -0,0 +1,23 @@
|
||||
select 'shell' as component,
|
||||
'js/code-editor.js' as javascript;
|
||||
|
||||
select
|
||||
'form' as component,
|
||||
COALESCE('Editing ' || $path, 'New page') as title,
|
||||
'insert_file.sql' as action;
|
||||
|
||||
select
|
||||
'path' as name,
|
||||
'text' as type,
|
||||
'Name' as label,
|
||||
$path as value,
|
||||
'test.sql' as placeholder;
|
||||
|
||||
select
|
||||
'textarea' as type,
|
||||
'contents' as name,
|
||||
'code-editor' as id,
|
||||
'Contents' as label,
|
||||
(select contents from sqlpage_files where path = $path) as value,
|
||||
'SELECT ''text'' as component,
|
||||
''Hello, world!'' as contents;' as placeholder;
|
||||
@@ -0,0 +1,29 @@
|
||||
select
|
||||
'list' as component,
|
||||
'Website files' as title;
|
||||
|
||||
select
|
||||
path as title,
|
||||
path as link,
|
||||
sqlpage.link ('edit.sql', json_build_object ('path', path)) as edit_link,
|
||||
sqlpage.link ('delete.sql', json_build_object ('path', path)) as delete_link
|
||||
from
|
||||
sqlpage_files;
|
||||
|
||||
select
|
||||
'Create new file' as title,
|
||||
'edit.sql' as link,
|
||||
'file-plus' as icon,
|
||||
'green' as color;
|
||||
|
||||
select 'list' as component,
|
||||
'Database tables' as title;
|
||||
|
||||
select
|
||||
table_name as title,
|
||||
sqlpage.link ('view_table.sql', json_build_object('table_name', table_name)) as link
|
||||
from
|
||||
information_schema.tables
|
||||
where
|
||||
table_schema = 'public'
|
||||
and table_type = 'BASE TABLE';
|
||||
@@ -0,0 +1,10 @@
|
||||
insert into sqlpage_files (path, contents)
|
||||
values (:path, :contents::bytea)
|
||||
on conflict (path)
|
||||
do update set contents = excluded.contents
|
||||
returning
|
||||
'redirect' as component,
|
||||
sqlpage.link(
|
||||
'index.sql',
|
||||
json_build_object('inserted', :path)
|
||||
) as link;
|
||||
@@ -0,0 +1,34 @@
|
||||
const cdn = "https://cdn.jsdelivr.net/npm/monaco-editor@0.50.0/";
|
||||
|
||||
function on_monaco_load() {
|
||||
// Create an editor div, display it after the '#code-editor' textarea, hide the textarea, and create a Monaco editor in the div with the contents of the textarea
|
||||
// When the form is submitted, set the value of the textarea to the value of the Monaco editor
|
||||
const textarea = document.getElementById("code-editor");
|
||||
const editorDiv = document.createElement("div");
|
||||
editorDiv.style.width = "100%";
|
||||
editorDiv.style.height = "700px";
|
||||
textarea.parentNode.insertBefore(editorDiv, textarea.nextSibling);
|
||||
const monacoConfig = {
|
||||
value: textarea.value,
|
||||
language: "sql",
|
||||
};
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
baseUrl: `${cdn}min/`,
|
||||
};
|
||||
const editor = monaco.editor.create(editorDiv, monacoConfig);
|
||||
textarea.style.display = "none";
|
||||
const form = textarea.form;
|
||||
form.addEventListener("submit", () => {
|
||||
textarea.value = editor.getValue();
|
||||
});
|
||||
}
|
||||
|
||||
function set_require_config() {
|
||||
require.config({ paths: { vs: `${cdn}min/vs` } });
|
||||
require(["vs/editor/editor.main"], on_monaco_load);
|
||||
}
|
||||
const loader_script = document.createElement("script");
|
||||
loader_script.src = `${cdn}min/vs/loader.js`;
|
||||
loader_script.onload = set_require_config;
|
||||
document.head.appendChild(loader_script);
|
||||
@@ -0,0 +1,15 @@
|
||||
select 'title' as component, $table_name as contents;
|
||||
select 'table' as component, true as search, true as sort;
|
||||
|
||||
select 'dynamic' as component, t as properties
|
||||
from table_contents($table_name) t
|
||||
LIMIT 1000;
|
||||
|
||||
select 'alert' as component,
|
||||
CASE
|
||||
WHEN COUNT(*) = 0 THEN 'The table is empty.'
|
||||
WHEN COUNT(*) > 1000 THEN 'Only the first 1000 rows are shown.'
|
||||
END as description,
|
||||
'info' as color
|
||||
from table_contents($table_name)
|
||||
HAVING NOT COUNT(*) BETWEEN 1 AND 1000;
|
||||
@@ -0,0 +1,10 @@
|
||||
# Remote Content Demo
|
||||
|
||||
This small SQLPage example shows how to:
|
||||
- lazy-load other page in cards including:
|
||||
- chart component rendered by sqlpage
|
||||
- map component rendered by sqlpage
|
||||
- table component rendered by sqlpage
|
||||
|
||||

|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
select
|
||||
'chart' as component,
|
||||
'Quarterly Revenue' as title,
|
||||
'area' as type,
|
||||
'indigo' as color,
|
||||
5 as marker,
|
||||
TRUE as time;
|
||||
select
|
||||
'2022-01-01T00:00:00Z' as x,
|
||||
15 as y;
|
||||
select
|
||||
'2022-04-01T00:00:00Z' as x,
|
||||
46 as y;
|
||||
select
|
||||
'2022-07-01T00:00:00Z' as x,
|
||||
23 as y;
|
||||
select
|
||||
'2022-10-01T00:00:00Z' as x,
|
||||
70 as y;
|
||||
select
|
||||
'2023-01-01T00:00:00Z' as x,
|
||||
35 as y;
|
||||
select
|
||||
'2023-04-01T00:00:00Z' as x,
|
||||
106 as y;
|
||||
select
|
||||
'2023-07-01T00:00:00Z' as x,
|
||||
53 as y;
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
@@ -0,0 +1,21 @@
|
||||
select
|
||||
'card' as component,
|
||||
2 as columns;
|
||||
select
|
||||
'A card with a Markdown description' as title,
|
||||
'This is a card with a **Markdown** description.
|
||||
|
||||
This is useful if you want to display a lot of text in the card, with many options for formatting, such as
|
||||
- **bold**,
|
||||
- *italics*,
|
||||
- [links](index.sql),
|
||||
- etc.' as description_md;
|
||||
select
|
||||
'A card with lazy-loaded chart' as title,
|
||||
'/chart-example.sql?_sqlpage_embed' as embed;
|
||||
select
|
||||
'A card with lazy-loaded map' as title,
|
||||
'/map-example.sql?_sqlpage_embed' as embed;
|
||||
select
|
||||
'A card with lazy-loaded table' as title,
|
||||
'/table-example.sql?_sqlpage_embed' as embed;
|
||||
@@ -0,0 +1,9 @@
|
||||
select
|
||||
'map' as component,
|
||||
1 as zoom;
|
||||
select
|
||||
'New Delhi' as title,
|
||||
28.6139 as latitude,
|
||||
77.209 as longitude;
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 139 KiB |
@@ -0,0 +1,13 @@
|
||||
select
|
||||
'table' as component,
|
||||
TRUE as sort,
|
||||
TRUE as search;
|
||||
select
|
||||
'Ophir' as Forename,
|
||||
'Lojkine' as Surname,
|
||||
'lovasoa' as Pseudonym;
|
||||
select
|
||||
'Linus' as Forename,
|
||||
'Torvalds' as Surname,
|
||||
'torvalds' as Pseudonym;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
xpath "count(//div[contains(@class, 'card')])" >= 4
|
||||
body contains "A card with a Markdown description"
|
||||
body contains "<strong>Markdown</strong>"
|
||||
body htmlUnescape contains "/chart-example.sql?_sqlpage_embed"
|
||||
body htmlUnescape contains "/map-example.sql?_sqlpage_embed"
|
||||
body htmlUnescape contains "/table-example.sql?_sqlpage_embed"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/chart-example.sql?_sqlpage_embed
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
xpath "normalize-space(//*[contains(., 'Quarterly Revenue')])" contains "Quarterly Revenue"
|
||||
body contains "2023-07-01T00:00:00Z"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/map-example.sql?_sqlpage_embed
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "New Delhi"
|
||||
body contains "28.6139"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/table-example.sql?_sqlpage_embed
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
xpath "normalize-space(//*[contains(., 'Forename')])" contains "Forename"
|
||||
body contains "Ophir"
|
||||
body contains "Torvalds"
|
||||
body not contains "An error occurred"
|
||||
@@ -0,0 +1,21 @@
|
||||
# TapTempo
|
||||
|
||||
This small SQLPage example shows how to :
|
||||
- save request logs to the database
|
||||
- display graphs
|
||||
- make computations on the database
|
||||
- write a custom component with HTML and CSS
|
||||
|
||||
## TapTempo: a webapp to measure the tempo of a song
|
||||
|
||||
TapTempo is a webapp that lets you measure the tempo of a song by tapping on a button in rhythm with the music.
|
||||
|
||||
It is a simple example of a webapp that stores data in a database, and displays graphs.
|
||||
|
||||
### Implementation
|
||||
|
||||
At each tap, the webapp sends a request to the server, which stores the timestamp of the tap in the `tap` table.
|
||||
|
||||
A [window function](https://www.sqlite.org/windowfunctions.html) is used to compute the tempo of the song from the timestamps of the last two taps.
|
||||
|
||||

|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
web:
|
||||
image: lovasoa/sqlpage:main
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- .:/var/www
|
||||
- ./sqlpage:/etc/sqlpage
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
@@ -0,0 +1,37 @@
|
||||
SELECT 'shell' AS component,
|
||||
'Tap Tempo' as title,
|
||||
'/' as link,
|
||||
'en-US' as lang,
|
||||
'A tool to measure a tempo in bpm by clicking a button in rythm.' as description,
|
||||
'Vollkorn' as font,
|
||||
'music' as icon,
|
||||
'Proudly powered by [SQLPage](https://sql-page.com)' as footer;
|
||||
|
||||
SELECT 'hero' as component,
|
||||
'Tap Tempo' as title,
|
||||
'Tap Tempo is a tool to **measure a tempo in bpm** by clicking a button in rythm.' as description_md,
|
||||
'drums by Nana Yaw Otoo.jpg' as image,
|
||||
sqlpage.link('taptempo.sql', json_object('session', random())) as link,
|
||||
'Start tapping !' as link_text;
|
||||
|
||||
SELECT 'text' as component,
|
||||
'About TapTempo' as title,
|
||||
'
|
||||
## Context
|
||||
|
||||
This tool is written in the SQL database query language, and uses the [SQLPage](https://sql-page.com) framework to generate the web interface.
|
||||
|
||||
It serves as a demo for the framework.
|
||||
|
||||
If what you really want is to measure a tempo, not learn about website building and databases,
|
||||
you should probably use something else, that does not require a web server and a database to run 😉.
|
||||
|
||||
## History
|
||||
|
||||
There is a large family of implementations of the tap tempo tool.
|
||||
|
||||
It originates from a french linux discussion website, [linuxfr.org](https://linuxfr.org/), where it was implemented in C++ by [mzf](https://linuxfr.org/users/mzf).
|
||||
|
||||
[See alternative implementations](https://linuxfr.org/wiki/taptempo).
|
||||
|
||||
' as contents_md;
|
||||
|
After Width: | Height: | Size: 90 KiB |
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE tap(
|
||||
tapping_session INTEGER,
|
||||
day REAL NOT NULL, -- fractional julian day, easy to manipulate in SQLite
|
||||
PRIMARY KEY (tapping_session, day)
|
||||
);
|
||||
|
||||
CREATE VIEW tap_bpm AS
|
||||
SELECT
|
||||
*,
|
||||
CAST(
|
||||
1 / ((24 * 60) * (day - previous))
|
||||
AS INTEGER
|
||||
) AS bpm
|
||||
FROM (
|
||||
SELECT
|
||||
*,
|
||||
lag(day) OVER (ORDER BY day) AS previous
|
||||
FROM tap
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
<a class="bigbutton" href="{{link}}">{{text}}</a>
|
||||
|
||||
<style>
|
||||
.bigbutton {
|
||||
/* A huge funky round button with a ton of visual effects */
|
||||
display: block;
|
||||
margin: auto;
|
||||
width: 20rem;
|
||||
height: 20rem;
|
||||
line-height: 18rem;
|
||||
font-size: 3rem;
|
||||
text-align: center;
|
||||
padding: 0.5em 1em;
|
||||
border: 2px solid #000;
|
||||
border-radius: 100%;
|
||||
box-shadow: 0 0.5em 0.5em -0.4em #eb7f26;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: all 0.05s;
|
||||
background: linear-gradient(to bottom, #ddd44f, #da6a36);
|
||||
text-shadow: 2px 2px 3px rgba(87, 34, 34, 0.5);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.bigbutton:hover {
|
||||
background: linear-gradient(to bottom, #dad161, #e65e20);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0.8em 0.7em -0.5em #eb7f26;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
SELECT 'shell' AS component, 'Tap Tempo' as title, 'Vollkorn' as font, 'music' as icon;
|
||||
|
||||
-- See https://www.sqlite.org/lang_datefunc.html (unixepoch is not available in the current version of SQLite)
|
||||
INSERT INTO tap(tapping_session, day) VALUES ($session, julianday('now'));
|
||||
|
||||
SELECT 'big_button' as component,
|
||||
COALESCE(
|
||||
(SELECT bpm || ' bpm' FROM tap_bpm WHERE tapping_session = $session ORDER BY day DESC LIMIT 1),
|
||||
'Tap'
|
||||
) AS text,
|
||||
sqlpage.link('taptempo.sql', json_object('session', $session)) as link;
|
||||
|
||||
SELECT 'chart' as component, 'BPM over time' as title, 'area' as type, 'indigo' as color, 0 AS ymin, 200 AS ymax, 'BPM' as ytitle;
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
strftime('%H:%M:%f', day) AS x,
|
||||
bpm AS y
|
||||
FROM tap_bpm
|
||||
WHERE tapping_session = $session
|
||||
ORDER BY day DESC LIMIT 10
|
||||
) ORDER BY x ASC;
|
||||
@@ -0,0 +1,22 @@
|
||||
GET http://localhost:8080/
|
||||
HTTP 200
|
||||
[Captures]
|
||||
start_url: xpath "string(//a[normalize-space()='Start tapping !']/@href)"
|
||||
[Asserts]
|
||||
body contains "Tap Tempo is a tool to"
|
||||
body contains "measure a tempo in bpm"
|
||||
body contains "About TapTempo"
|
||||
|
||||
GET http://localhost:8080/{{start_url}}
|
||||
HTTP 200
|
||||
[Captures]
|
||||
tap_url: xpath "string(//a[normalize-space()='Tap']/@href)"
|
||||
[Asserts]
|
||||
body contains "Tap"
|
||||
body contains "BPM over time"
|
||||
|
||||
GET http://localhost:8080/{{tap_url}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains " bpm"
|
||||
body contains "BPM over time"
|
||||
@@ -0,0 +1 @@
|
||||
sqlpage.db
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM lovasoa/sqlpage:main
|
||||
|
||||
COPY ./sqlpage /etc/sqlpage
|
||||
COPY . /var/www
|
||||
@@ -0,0 +1,5 @@
|
||||
INSERT INTO games(id)
|
||||
VALUES(random())
|
||||
RETURNING
|
||||
'redirect' as component,
|
||||
CONCAT('game.sql?id=', id) as link;
|
||||
@@ -0,0 +1,38 @@
|
||||
# Corporate Conundrum: Decisions in Disguise
|
||||
|
||||
This is the web version of a board game, coded entirely in SQL.
|
||||
|
||||
## Pitch
|
||||
Unleash your inner executive as you step into the shoes of top-level employees in a high-stakes corporate meeting. But beware, among your trusted colleagues lurks a cunning infiltrator sent by a rival company to sabotage your decision-making process. Will you be able to outsmart the saboteur and make the right choices to lead your company to success?
|
||||
|
||||
## Rules
|
||||
|
||||
**Objective**: As a team of genuine employees, your goal is to make accurate decisions based on challenging questions. The infiltrator's objective is to sway you toward incorrect answers.
|
||||
|
||||
**Gameplay**: Each turn, a question will be presented to the group. One player, secretly assigned as the infiltrator, will receive a specific wrong answer. They must cunningly lead others astray, while you must collaborate and deduce the correct answer.
|
||||
|
||||
**Discussion Phase**: Engage in lively debates and exchange ideas to uncover the truth. Analyze arguments, question motives, and use your critical thinking skills to navigate the murky waters of corporate deception.
|
||||
|
||||
**Hidden Votes**: After the discussion phase, all players simultaneously submit their individual answers privately, without revealing them to others.
|
||||
|
||||
**Scoring System**: Points are awarded based on the proximity of each player's answer to the true answer.
|
||||
|
||||
- If a player's answer is closer to the true answer than to the wrong answer provided by the saboteur, they earn one point.
|
||||
- Conversely, if a player's answer is closer to the wrong answer, they inadvertently contribute one point to the saboteur's score.
|
||||
|
||||
**Continuing Gameplay**: The game progresses with new questions and role assignments, allowing each player to take turns as the infiltrator. The player with the highest score at the end of the predetermined number of rounds wins the game.
|
||||
|
||||
|
||||
## Web app usage
|
||||
|
||||
Create a new game by clicking the "New Game" button. You will be prompted to add new players to the game by adding their names. Share the game URL with other players so they can join the game.
|
||||
|
||||
Role Assignment: The web app will randomly assign roles to players, designating one as the infiltrator.
|
||||
|
||||
Question and Wrong Answer: The web app will display the question to all players, and only the infiltrator will see an additional mention with the assigned wrong answer.
|
||||
|
||||
Discussion and Decision-making: You need to be in the same room as the other players, and discuss the question and the wrong answer. The infiltrator will try to convince you to choose the wrong answer.
|
||||
|
||||
Answer Submission: Once the discussion phase ends, enter your individual answer into the web app. The web app will track points earned based on the accuracy of each player's answer.
|
||||
|
||||
Get ready to immerse yourself in the thrilling world of corporate espionage, where every decision is shrouded in uncertainty. Can you decipher the truth from deceit and lead your company to victory? Prepare for "Corporate Conundrum: Decisions in Disguise" and prove your mettle as a strategic mastermind!
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DATABASE_URL: sqlite:///tmp/corporate-conundrum.db?mode=rwc
|
||||
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,44 @@
|
||||
select * FROM sqlpage_shell;
|
||||
|
||||
-- Game over, questions breakdown
|
||||
select 'card' as component,
|
||||
2 AS columns,
|
||||
'The game is over' as title,
|
||||
'Breaking down the results per question' as description;
|
||||
SELECT question_text as title,
|
||||
'The true answer was ' || true_answer || '. ' || impostor || ' tried to make everyone think it was ' || wrong_answer || '. ' || (
|
||||
select group_concat(
|
||||
player_name || ' voted ' || answer_value || CASE
|
||||
WHEN abs(answer_value - true_answer) < abs(answer_value - wrong_answer)
|
||||
THEN ' and earned a point'
|
||||
WHEN player_name = impostor
|
||||
THEN ' and did not earn a point'
|
||||
ELSE ' and gave a point to ' || impostor
|
||||
END,
|
||||
', '
|
||||
)
|
||||
from answers
|
||||
where answers.game_id = $game_id::integer
|
||||
and answers.question_id = questions.id
|
||||
) || '.' as description,
|
||||
explanation as footer
|
||||
FROM game_questions
|
||||
JOIN questions ON game_questions.question_id = questions.id
|
||||
WHERE game_id = $game_id::integer
|
||||
ORDER BY game_order;
|
||||
|
||||
-- Point count
|
||||
SELECT 'chart' as component,
|
||||
'Scores' as title,
|
||||
'bar' as type;
|
||||
SELECT name as label,
|
||||
(
|
||||
select sum(
|
||||
(players.name = player_name AND NOT impostor_won)
|
||||
OR (players.name != player_name AND players.name = impostor AND impostor_won)
|
||||
)
|
||||
FROM game_results WHERE game_id = $game_id
|
||||
) as value
|
||||
FROM players
|
||||
WHERE game_id = $game_id::integer
|
||||
ORDER BY value DESC;
|
||||
@@ -0,0 +1,57 @@
|
||||
select * FROM sqlpage_shell;
|
||||
|
||||
-- Display the list of players with a link for each one to start playing
|
||||
INSERT INTO players(name, game_id)
|
||||
SELECT $Player as name,
|
||||
CAST($id AS INTEGER) as game_id
|
||||
WHERE $Player IS NOT NULL;
|
||||
|
||||
SELECT 'list' as component,
|
||||
'Players' as title;
|
||||
SELECT name as title,
|
||||
sqlpage.link(
|
||||
'next-question.sql',
|
||||
json_object(
|
||||
'game_id', game_id,
|
||||
'player', name
|
||||
)
|
||||
) as link
|
||||
FROM players
|
||||
WHERE game_id = CAST($id AS INTEGER);
|
||||
---------------------------
|
||||
-- Player insertion form --
|
||||
---------------------------
|
||||
SELECT 'form' as component,
|
||||
'Add a new player' as title,
|
||||
'Add to game' as validate;
|
||||
SELECT 'Player' as name,
|
||||
'Player name' as placeholder,
|
||||
TRUE as autofocus;
|
||||
-- Insert a new question into the game_questions table for each new player
|
||||
INSERT INTO game_questions(
|
||||
game_id,
|
||||
question_id,
|
||||
wrong_answer,
|
||||
impostor,
|
||||
game_order
|
||||
)
|
||||
SELECT CAST($id AS INTEGER) as game_id,
|
||||
questions.id as question_id,
|
||||
-- When the true answer is small, set the wrong answer to just +/- 1, otherwise -25%/+75%.
|
||||
-- When it is a date between 1200 and 2100, make it -25 % or +75 % of the distance to today
|
||||
CAST(CASE
|
||||
WHEN questions.true_answer < 10 THEN questions.true_answer + 1 - 2 * abs(random() %2) -- wrong answer = true answer +/- 1
|
||||
WHEN questions.true_answer > 1200
|
||||
AND questions.true_answer < 2100 THEN 2023 - (2023 - questions.true_answer) * (abs(random() %2) + 0.75) -- wrong answer = true answer -25% or +75% of the distance to today
|
||||
ELSE questions.true_answer * (abs(random() %2) + 0.75)
|
||||
END AS INTEGER) as wrong_answer,
|
||||
-- wrong answer = true answer +/- 50% TODO: better wrong answer generation
|
||||
$Player as impostor,
|
||||
random() as game_order
|
||||
FROM questions
|
||||
LEFT JOIN game_questions ON questions.id = game_questions.question_id
|
||||
AND game_questions.game_id = CAST($id AS INTEGER)
|
||||
WHERE game_questions.question_id IS NULL
|
||||
AND $Player IS NOT NULL
|
||||
ORDER BY random()
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,22 @@
|
||||
select * FROM sqlpage_shell;
|
||||
SELECT 'hero' as component,
|
||||
'Welcome to Corporate Conundrum' as title,
|
||||
'Unleash your inner executive in this thrilling board game of corporate espionage. Make the right choices to lead your company to success!' as description,
|
||||
'New Game.sql' as link,
|
||||
'Start a New Game' as link_text;
|
||||
SELECT 'Lively discussions' as title,
|
||||
'Each turn, a question will be presented to the group. One player will be assigned as the infiltrator and receive a specific wrong answer. Engage in lively real-life debates and exchange ideas to uncover the truth and make accurate decisions.' as description,
|
||||
'help-hexagon' as icon,
|
||||
'blue' as color;
|
||||
SELECT 'Hidden Votes' as title,
|
||||
'After the discussion phase, all players submit their individual answers privately. Points are awarded based on their proximity to the true answer.' as description,
|
||||
'file' as icon,
|
||||
'green' as color;
|
||||
SELECT 'Role Assignment' as title,
|
||||
'The web app randomly assigns roles to players, designating one as the infiltrator. The infiltrator''s objective is to sway others toward incorrect answers, while the team tries to collaborate and deduce the correct answer.' as description,
|
||||
'spy' as icon,
|
||||
'red' as color;
|
||||
SELECT 'Continuing Gameplay' as title,
|
||||
'The game progresses with new questions and role assignments, allowing each player to take turns as the infiltrator. The player with the highest score at the end of the predetermined number of rounds wins the game.' as description,
|
||||
'player-play' as icon,
|
||||
'purple' as color;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- We need to redirect the user to the next question in the game if there is one, or to the game over page if there are no more questions.
|
||||
with next_question as (
|
||||
SELECT
|
||||
'question.sql' as page,
|
||||
json_object(
|
||||
'game_id', $game_id,
|
||||
'question_id', game_questions.question_id,
|
||||
'player', $player
|
||||
) as params
|
||||
FROM game_questions
|
||||
WHERE game_id = $game_id::integer
|
||||
AND NOT EXISTS (
|
||||
-- This will filter out questions that have already been answered by the player
|
||||
SELECT 1
|
||||
FROM answers
|
||||
WHERE answers.game_id = game_questions.game_id
|
||||
AND answers.player_name = $player
|
||||
AND answers.question_id = game_questions.question_id
|
||||
)
|
||||
ORDER BY game_order
|
||||
LIMIT 1
|
||||
),
|
||||
next_page as (
|
||||
SELECT * FROM next_question
|
||||
UNION ALL
|
||||
SELECT 'game-over.sql' as page, json_object('game_id', $game_id) as params
|
||||
WHERE NOT EXISTS (SELECT 1 FROM next_question)
|
||||
)
|
||||
SELECT 'redirect' as component,
|
||||
sqlpage.link(page, params) as link
|
||||
FROM next_page
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,34 @@
|
||||
select * FROM sqlpage_shell;
|
||||
|
||||
SELECT 'form' as component,
|
||||
question_text as title,
|
||||
'Submit your answer' as validate,
|
||||
sqlpage.link('wait.sql', json_object('game_id', $game_id, 'question_id', $question_id, 'player', $player)) as action
|
||||
FROM questions
|
||||
where id = $question_id::integer;
|
||||
|
||||
SELECT 'answer' as name,
|
||||
CASE
|
||||
$player
|
||||
WHEN impostor THEN 'Try to trick the other players into answering: ' || wrong_answer || ', but try to make your own answer correct.'
|
||||
ELSE 'Discuss the question with the other players and then submit your answer'
|
||||
END as label,
|
||||
'Your answer' as placeholder,
|
||||
'number' as type,
|
||||
TRUE as required,
|
||||
TRUE as autofocus,
|
||||
0 as min
|
||||
FROM game_questions
|
||||
WHERE game_id = $game_id::integer
|
||||
AND question_id = $question_id::integer;
|
||||
|
||||
SELECT 'alert' as component,
|
||||
'red' as color,
|
||||
'Make them guess: ' || wrong_answer as title,
|
||||
'You are the impostor!
|
||||
Your goal is to sabotage the game by making others give an answer that will be closer to ' || wrong_answer || ' than to the true answer.
|
||||
The more other players you manage to trick, the more points you will get.' as description
|
||||
FROM game_questions
|
||||
WHERE game_id = $game_id::integer
|
||||
AND impostor = $player
|
||||
AND question_id = $question_id::integer;
|
||||
@@ -0,0 +1,30 @@
|
||||
select * FROM sqlpage_shell;
|
||||
SELECT 'steps' as component,
|
||||
1 as counter,
|
||||
'cyan' as color,
|
||||
'Game rules' as title;
|
||||
SELECT 'Create game' as title,
|
||||
'plus' as icon,
|
||||
'Create a new game from the home page.' as description;
|
||||
SELECT 'Add players' as title,
|
||||
'user-plus' as icon,
|
||||
'Add players by their name, and send them their own unique game URL.' as description;
|
||||
SELECT 'Answer questions' as title,
|
||||
'Answer trivia questions to get points. Don''t be fooled by the imposter.' as description,
|
||||
'help-hexagon' as icon;
|
||||
SELECT 'Trick the others' as title,
|
||||
'When you become the imposter, try to trick the others into giving wrong answers.' as description,
|
||||
'help-hexagon' as icon;
|
||||
SELECT 'The smartest wins' as title,
|
||||
'In the end, the game counts your points. You win if you tricked the others and did not get tricked.' as description,
|
||||
'brain' as icon;
|
||||
|
||||
select 'card' as component, 1 as columns;
|
||||
SELECT 'Objective' as title, 'As a team of genuine employees, your goal is to make accurate decisions based on challenging questions. The infiltrator''s objective is to sway you toward incorrect answers.' as description;
|
||||
SELECT 'Gameplay' as title, 'Each turn, a question will be presented to the group. One player, secretly assigned as the infiltrator, will receive a specific wrong answer. They must cunningly lead others astray, while you must collaborate and deduce the correct answer. Every player will be the infiltrator once during the game.' as description;
|
||||
SELECT 'Discussion Phase' as title, 'Engage in lively debates and exchange ideas to uncover the truth. Analyze arguments, question motives, and use your critical thinking skills to navigate the murky waters of corporate deception.' as description;
|
||||
SELECT 'Hidden Votes' as title, 'After the discussion phase, all players simultaneously submit their individual answers privately, without revealing them to others. Votes will not be revealed until the end of the game.' as description;
|
||||
SELECT 'Scoring System' as title, 'Points are awarded based on the proximity of each player''s answer to the true answer.
|
||||
If a player''s answer is closer to the true answer than to the wrong answer provided by the saboteur, they earn one point.
|
||||
Conversely, if a player''s answer is closer to the wrong answer, they inadvertently contribute one point to the saboteur''s score.' as description;
|
||||
SELECT 'Continuing Gameplay' as title, 'The game progresses with new questions and role assignments, allowing each player to take turns as the infiltrator. The player with the highest score at the end of the predetermined number of rounds wins the game.' as description;
|
||||
@@ -0,0 +1,63 @@
|
||||
CREATE TABLE games (
|
||||
id INTEGER PRIMARY KEY,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE players (
|
||||
name TEXT NOT NULL,
|
||||
game_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (name, game_id),
|
||||
FOREIGN KEY (game_id) REFERENCES games(id)
|
||||
);
|
||||
|
||||
CREATE TABLE questions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
question_text TEXT,
|
||||
category CHAR(4),
|
||||
explanation TEXT,
|
||||
true_answer INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE game_questions (
|
||||
game_id INTEGER,
|
||||
question_id INTEGER,
|
||||
wrong_answer INTEGER, -- the wrong answer that the impostor will try to convince others is correct
|
||||
impostor TEXT, -- the player who will receive the wrong answer
|
||||
game_order INTEGER, -- indicates the order in which the questions are asked
|
||||
PRIMARY KEY (game_id, question_id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||
FOREIGN KEY (game_id, impostor) REFERENCES players(game_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE answers (
|
||||
game_id INTEGER,
|
||||
player_name TEXT,
|
||||
question_id INTEGER,
|
||||
answer_value INTEGER,
|
||||
PRIMARY KEY (game_id, question_id, player_name),
|
||||
FOREIGN KEY (game_id) REFERENCES games(id),
|
||||
FOREIGN KEY (player_name, game_id) REFERENCES players(name, game_id),
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||
);
|
||||
|
||||
CREATE VIEW game_results AS
|
||||
SELECT answers.game_id,
|
||||
player_name,
|
||||
impostor,
|
||||
abs(answer_value - true_answer) > abs(answer_value - wrong_answer) as impostor_won
|
||||
FROM answers
|
||||
INNER JOIN game_questions ON answers.question_id = game_questions.question_id AND answers.game_id = game_questions.game_id
|
||||
INNER JOIN questions ON game_questions.question_id = questions.id;
|
||||
|
||||
-- transform the above to a create view
|
||||
CREATE VIEW sqlpage_shell AS
|
||||
SELECT
|
||||
'shell' AS component,
|
||||
'Corporate Conundrum' AS title,
|
||||
'Unleash your inner executive in this thrilling board game of corporate espionage. Make the right choices to lead your company to success!' AS description,
|
||||
'affiliate' AS icon,
|
||||
'/' AS link,
|
||||
'["New Game", "rules"]' AS menu_item,
|
||||
'Libre Baskerville' AS font,
|
||||
'en-US' AS lang
|
||||
;
|
||||
@@ -0,0 +1,497 @@
|
||||
INSERT INTO questions (
|
||||
question_text,
|
||||
category,
|
||||
explanation,
|
||||
true_answer
|
||||
)
|
||||
VALUES (
|
||||
'How tall is the Eiffel Tower in meters (including the tip)?',
|
||||
'GEOG',
|
||||
'The Eiffel Tower stands at a height of 330 meters, including the tip. This measurement includes the pinnacle that crowns the tower, providing an accurate representation of its total height from the ground to the highest point.',
|
||||
330
|
||||
),
|
||||
(
|
||||
'When was the first submarine built?',
|
||||
'HIST',
|
||||
'The first submersible of whose construction there exists reliable information was designed and built in 1620 by Cornelis Drebbel, a Dutchman in the service of James I of England.',
|
||||
1620
|
||||
),
|
||||
(
|
||||
'How many planets are in our solar system?',
|
||||
'SCIE',
|
||||
'There are eight planets in our solar system: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, and Neptune.',
|
||||
8
|
||||
),
|
||||
(
|
||||
'How many bones are there in the human body?',
|
||||
'SCIE',
|
||||
'The human body has 206 bones. This count includes all the bones in the skeleton, ranging from the tiny bones in the ear to the larger bones in the arms, legs, spine, and skull. The number of bones can vary slightly due to fusion of certain bones during growth and development.',
|
||||
206
|
||||
),
|
||||
(
|
||||
'How many symphonies did Ludwig van Beethoven compose?',
|
||||
'MUSI',
|
||||
'Recognised as one of the greatest and most influential composers of the Western classical tradition, he defied the onset of deafness from the age of 28 to produce an output that encompasses 722 works, including 9 symphonies, 35 piano sonatas and 16 string quartets.',
|
||||
9
|
||||
),
|
||||
(
|
||||
'In what year did George Washington die?',
|
||||
'HIST',
|
||||
'George Washington (February 22, 1732 – December 14, 1799) was an American political leader, military general, statesman, and Founding Father who served as the first president of the United States from 1789 to 1797.',
|
||||
1799
|
||||
),
|
||||
(
|
||||
'What is the size (diameter) of Jupiter in kilometers ?',
|
||||
'SCIE',
|
||||
'Jupiter is the largest planet in our solar system at nearly 11 times the size of Earth and 317 times its mass.',
|
||||
139820
|
||||
),
|
||||
(
|
||||
'What is the population of Australia in millions ?',
|
||||
'GEOG',
|
||||
'The population of Australia is estimated to be around 27 millions in 2023. Australia is the 55th most populous country in the world.',
|
||||
27
|
||||
),
|
||||
(
|
||||
'what is the population of Kazakhstan in millions ?',
|
||||
'GEOG',
|
||||
'It has a population of 19 million people and one of the lowest population densities in the world, at fewer than 6 people per square kilometre.',
|
||||
19
|
||||
),
|
||||
(
|
||||
'How many stairs are there in the Eiffel Tower?',
|
||||
'GEOG',
|
||||
'There are 1665 steps in the Eiffel Tower.',
|
||||
1665
|
||||
),
|
||||
(
|
||||
'How heavy is the Empire State Building in tons?',
|
||||
'GEOG',
|
||||
'The Empire State Building weighs approximately 365,000 tons.',
|
||||
365000
|
||||
),
|
||||
(
|
||||
'How many people die in car crashes globally every day?',
|
||||
'SCIE',
|
||||
'Approximately 3,700 people die in car crashes every day around the world.',
|
||||
3700
|
||||
),
|
||||
(
|
||||
'How many liters of pure alcohol do people drink per year in the world ?',
|
||||
'SCIE',
|
||||
'The average person (aged 15 years or older) consumes 6 liters of pure alcohol per year, according to the latest data from the World Health Organization.',
|
||||
6
|
||||
),
|
||||
(
|
||||
'In what year was Nigeria formed ?',
|
||||
'HIST',
|
||||
'Lagos was occupied by British forces in 1851 and formally annexed by Britain in the year 1865. Nigeria became a British protectorate in 1901.The period of British rule lasted until 1960 when an independence movement led to the country being granted independence.',
|
||||
1960
|
||||
),
|
||||
(
|
||||
'In what year was Gandhi assassinated ?',
|
||||
'HIST',
|
||||
'Mohandas Karamchand Gandhi was assassinated on 30 January 1948 in the compound of Birla House (now Gandhi Smriti), a large mansion in New Delhi.',
|
||||
1948
|
||||
),
|
||||
(
|
||||
'How many babies are born each second in the world ?',
|
||||
'SCIE',
|
||||
'Around 140 million babies are born every year in the world. That''s a little more than four births every second of every day.',
|
||||
4
|
||||
),
|
||||
(
|
||||
'How old was the oldest iguana to ever live ?',
|
||||
'SCIE',
|
||||
'An almost 41-year-old Rhinoceros Iguana in Australia Zoo has made won an honour for world''s oldest living one in captivity. Iguana named Rhino, which is soon to turn 41 received the Guinness World Record for being the World''s Oldest Iguana.',
|
||||
41
|
||||
),
|
||||
(
|
||||
'How many countries are there in Africa?',
|
||||
'GEOG',
|
||||
'There are 54 countries in Africa.',
|
||||
54
|
||||
),
|
||||
(
|
||||
'How many players are there on a polo team?',
|
||||
'SPORTS',
|
||||
'A polo team is comprised of four players.The object of the game is to move the polo ball down the field,
|
||||
hitting the ball through the goal posts to score.',
|
||||
4
|
||||
),
|
||||
(
|
||||
'What is the atomic number of oxygen?',
|
||||
'SCIE',
|
||||
'The atomic number of oxygen is 8.',
|
||||
8
|
||||
),
|
||||
(
|
||||
'What is the current population of China in millions?',
|
||||
'GEOG',
|
||||
'The current population of China is 1.4 billion.',
|
||||
1444
|
||||
),
|
||||
(
|
||||
'How many planets are closer to the Sun than Earth?',
|
||||
'SCIE',
|
||||
'There are two planets closer to the Sun than Earth: Mercury and Venus.',
|
||||
2
|
||||
),
|
||||
(
|
||||
'How many time zones are there in Kazakhstan ?',
|
||||
'GEOG',
|
||||
'Even though its territory spans four geographical time zones (from +3 to +6), standard time in Kazakhstan
|
||||
is either UTC+05:00 or UTC+06:00.
|
||||
These times apply throughout the year as Kazakhstan does not observe Daylight saving time.',
|
||||
2
|
||||
),
|
||||
(
|
||||
'What is the speed limit in the United Arab Emirates on the Sheikh Khalifa highway in kilometers per hour ?',
|
||||
'SCIE',
|
||||
'The UAE is notable for having the highest posted speed limits in the world, with two major highways, the Abu Dhabi-Al Ain highway and the Sheikh Khalifa highway, both
|
||||
having limits of 160 km / h (99 mph).Speed limits in the Emirate of Abu Dhabi are generally higher than the other Emirates.The general speed
|
||||
limit in Abu Dhabi is 140 km / h whereas in Dubai it is 110 km / h and in the Northern Emirates its 120km / h.',
|
||||
160
|
||||
),
|
||||
(
|
||||
'How many chambers are there in a human heart?',
|
||||
'SCIE',
|
||||
'There are four chambers in a human heart: two atria and two ventricles.',
|
||||
4
|
||||
),
|
||||
(
|
||||
'What is the highest score achievable in a game of bowling?',
|
||||
'SPORTS',
|
||||
'A perfect game is the highest score possible in a game of bowling, achieved by scoring a strike in every frame.
|
||||
In common bowling games (that use 10 pins) the highest possible score is 300, achieved by bowling 12 strikes
|
||||
in a row in a traditional single game: one strike in each of the first nine frames, and three more in the tenth frame.',
|
||||
300
|
||||
),
|
||||
(
|
||||
'How many bones are there in the human body?',
|
||||
'SCIE',
|
||||
'An adult human body has 206 bones.',
|
||||
206
|
||||
),
|
||||
(
|
||||
'What is the population of India in millions?',
|
||||
'GEOG',
|
||||
'The population of India is around 1408 millions.',
|
||||
1408
|
||||
),
|
||||
(
|
||||
'How many Grand Slam titles has Serena Williams won?',
|
||||
'SPORTS',
|
||||
'Serena Williams has won 23 Grand Slam singles titles.',
|
||||
23
|
||||
),
|
||||
(
|
||||
'What is the record for the fastest 100-meter sprint in milliseconds?',
|
||||
'SPORTS',
|
||||
'The current record for the fastest 100-meter sprint is 9.58 seconds, set by Usain Bolt in 2009.',
|
||||
9580
|
||||
),
|
||||
(
|
||||
'How many elements are there in the periodic table?',
|
||||
'SCIE',
|
||||
'There are currently 118 known elements in the periodic table.',
|
||||
118
|
||||
),
|
||||
(
|
||||
'What is the diameter of the Moon in kilometers?',
|
||||
'SCIE',
|
||||
'The diameter of the Moon is approximately 3,474 kilometers.',
|
||||
3474
|
||||
),
|
||||
(
|
||||
'How many strings does a violin have?',
|
||||
'MUSI',
|
||||
'A violin typically has 4 strings.',
|
||||
4
|
||||
),
|
||||
(
|
||||
'What is the speed of sound in meters per second?',
|
||||
'SCIE',
|
||||
'The speed of sound in dry air at 20 degrees Celsius is approximately 343 meters per second.',
|
||||
343
|
||||
),
|
||||
(
|
||||
'How many Oscars did the movie "Titanic" win?',
|
||||
'MUSI',
|
||||
'The movie "Titanic" won 11 Oscars.',
|
||||
11
|
||||
),
|
||||
(
|
||||
'In what year did the Battle of Agincourt take place?',
|
||||
'HIST',
|
||||
'The Battle of Agincourt was a major English victory in the Hundred Years'' War. It was fought on 25 October 1415.',
|
||||
1415
|
||||
),
|
||||
(
|
||||
'When did the Ming Dynasty establish its rule in China?',
|
||||
'HIST',
|
||||
'The Ming Dynasty ruled China from 1368 to 1644, following the collapse of the Mongol-led Yuan Dynasty.',
|
||||
1368
|
||||
),
|
||||
(
|
||||
'When did the Byzantine Empire fall to the Ottoman Turks?',
|
||||
'HIST',
|
||||
'The Fall of Constantinople was the capture of the capital of the Byzantine Empire by an invading Ottoman army on 29 May 1453.',
|
||||
1453
|
||||
),
|
||||
(
|
||||
'When did the Black Death pandemic arrive in Europe?',
|
||||
'HIST',
|
||||
'The Black Death, also known as the Bubonic Plague, ravaged Europe from 1347 to 1351, causing widespread death and social upheaval.',
|
||||
1347
|
||||
),
|
||||
(
|
||||
'In what year did the Opium Wars between China and Britain start?',
|
||||
'HIST',
|
||||
'The Opium Wars were a series of conflicts between the Qing Dynasty of China and the British Empire. The First Opium War occurred from 1839 to 1842.',
|
||||
1839
|
||||
),
|
||||
(
|
||||
'When was the Great Fire of London?',
|
||||
'HIST',
|
||||
'The Great Fire of London was a major conflagration that swept through the central parts of the city from 2 to 6 September 1666.',
|
||||
1666
|
||||
),
|
||||
(
|
||||
'In what year did the Sengoku period start in Japan?',
|
||||
'HIST',
|
||||
'The Sengoku period (''Warring States period'') is the period in Japanese history in which civil wars and social upheavals
|
||||
took place almost continuously in the 15th and 16th centuries.
|
||||
Though the Ōnin War (1467) is generally chosen as the Sengoku period''s start date,
|
||||
there are many competing historiographies for its end date, ranging from 1568, the date of Oda Nobunaga''s march on Kyoto,
|
||||
to the suppression of the Shimabara Rebellion in 1638',
|
||||
1467
|
||||
),
|
||||
(
|
||||
'In what year was "Pride and Prejudice" by Jane Austen first published?',
|
||||
'LITE',
|
||||
'Jane Austen''s "Pride and Prejudice" was first published in 1813. It is a classic novel of manners that explores themes of love, marriage, and societal expectations.',
|
||||
1813
|
||||
),
|
||||
(
|
||||
'How many chapters are there in "Moby-Dick" by Herman Melville?',
|
||||
'LITE',
|
||||
'"Moby-Dick" by Herman Melville consists of 135 chapters. This epic novel tells the story of Captain Ahab''s quest for revenge against the white whale, Moby Dick.',
|
||||
135
|
||||
),
|
||||
(
|
||||
'In what year was "To Kill a Mockingbird" by Harper Lee published?',
|
||||
'LITE',
|
||||
'"To Kill a Mockingbird" was first published in 1960. The novel explores themes of racial injustice and the loss of innocence through the eyes of Scout Finch.',
|
||||
1960
|
||||
),
|
||||
(
|
||||
'How many volumes make up Marcel Proust''s "In Search of Lost Time"?',
|
||||
'LITE',
|
||||
'Marcel Proust''s "In Search of Lost Time" is a seven-volume novel. It is considered one of the greatest works of modernist literature.',
|
||||
7
|
||||
),
|
||||
(
|
||||
'In what year was "The Catcher in the Rye" by J.D. Salinger published?',
|
||||
'LITE',
|
||||
'"The Catcher in the Rye" was first published in 1951. The novel follows the rebellious teenager Holden Caulfield as he navigates adolescence and society.',
|
||||
1951
|
||||
),
|
||||
(
|
||||
'How many sisters are there in Louisa May Alcott''s "Little Women"?',
|
||||
'LITE',
|
||||
'"Little Women" by Louisa May Alcott features four sisters: Meg, Jo, Beth, and Amy. The novel explores their coming of age and their relationships with each other.',
|
||||
4
|
||||
),
|
||||
(
|
||||
'In what year was "1984" by George Orwell published?',
|
||||
'LITE',
|
||||
'"1984" by George Orwell was published in 1949. It is a dystopian novel that depicts a totalitarian society where individuality and independent thinking are suppressed.',
|
||||
1949
|
||||
),
|
||||
(
|
||||
'How many chapters are there in F. Scott Fitzgerald''s "The Great Gatsby"?',
|
||||
'LITE',
|
||||
'"The Great Gatsby" by F. Scott Fitzgerald consists of 9 chapters. The novel explores themes of wealth, love, and the American Dream during the Roaring Twenties.',
|
||||
9
|
||||
),
|
||||
(
|
||||
'In what year was "Don Quixote" by Miguel de Cervantes first published?',
|
||||
'LITE',
|
||||
'"Don Quixote" by Miguel de Cervantes was first published in 1605. It is considered one of the most important works of literature and is often regarded as the first modern novel.',
|
||||
1605
|
||||
),
|
||||
(
|
||||
'How many volumes make up J.R.R. Tolkien''s "The Lord of the Rings" trilogy?',
|
||||
'LITE',
|
||||
'J.R.R. Tolkien''s "The Lord of the Rings" trilogy consists of three volumes: "The Fellowship of the Ring," "The Two Towers," and "The Return of the King."',
|
||||
3
|
||||
),
|
||||
(
|
||||
'In what year was "Crime and Punishment" by Fyodor Dostoevsky first published?',
|
||||
'LITE',
|
||||
'"Crime and Punishment" by Fyodor Dostoevsky was first published in 1866. The novel explores the psychological turmoil of the main character, Raskolnikov.',
|
||||
1866
|
||||
),
|
||||
(
|
||||
'How many parts are there in Victor Hugo''s "Les Misérables"?',
|
||||
'LITE',
|
||||
'"Les Misérables" by Victor Hugo is divided into five parts. The novel follows the lives of several characters amidst the backdrop of social and political unrest in 19th-century France.',
|
||||
5
|
||||
),
|
||||
(
|
||||
'In what year was "The Hobbit" by J.R.R. Tolkien published?',
|
||||
'LITE',
|
||||
'"The Hobbit" by J.R.R. Tolkien was first published in 1937. It is a fantasy novel that serves as a prelude to Tolkien''s later work, "The Lord of the Rings."',
|
||||
1937
|
||||
),
|
||||
(
|
||||
'How many players are there in a Kabaddi team?',
|
||||
'SPORTS',
|
||||
'A Kabaddi team consists of 7 players on the field. Kabaddi is a popular contact team sport originating from ancient India.',
|
||||
7
|
||||
),
|
||||
(
|
||||
'What is the maximum score achievable with a single dart in a game of darts?',
|
||||
'SPORTS',
|
||||
'The maximum score achievable with a single dart in a game of darts is 60. This can be achieved by hitting the triple 20 segment.',
|
||||
60
|
||||
),
|
||||
(
|
||||
'How many players are there on an Australian Rules Football team?',
|
||||
'SPORTS',
|
||||
'An Australian Rules Football team has 18 players on the field at any given time. Australian Rules Football is a unique and popular sport in Australia.',
|
||||
18
|
||||
),
|
||||
(
|
||||
'What is the length of a cricket pitch in yards?',
|
||||
'SPORTS',
|
||||
'The length of a cricket pitch is 22 yards, which is equivalent to approximately 20.12 meters. Cricket is a widely played sport in many countries.',
|
||||
22
|
||||
),
|
||||
(
|
||||
'How many players are there on a water polo team?',
|
||||
'SPORTS',
|
||||
'A water polo team consists of 7 players in the water at a time, including the goalkeeper. Water polo is a competitive water sport played in a pool.',
|
||||
7
|
||||
),
|
||||
(
|
||||
'What is the weight of a standard table tennis ball in milligrams?',
|
||||
'SPORTS',
|
||||
'A standard table tennis ball weighs approximately 2.7 grams. Table tennis, also known as ping pong, is a popular indoor sport.',
|
||||
2700
|
||||
),
|
||||
(
|
||||
'How many rounds are there in professional boxing championship fights?',
|
||||
'SPORTS',
|
||||
'Professional boxing championship fights typically have 12 rounds. Each round is usually 3 minutes long, with a minute of rest in between.',
|
||||
12
|
||||
),
|
||||
(
|
||||
'What is the maximum score a gymnast can achieve on a single apparatus in artistic gymnastics?',
|
||||
'SPORTS',
|
||||
'In artistic gymnastics, the maximum score a gymnast can achieve on a single apparatus is 10. The score is based on a combination of difficulty and execution.',
|
||||
10
|
||||
),
|
||||
(
|
||||
'What is the length of an Olympic-sized swimming pool in meters?',
|
||||
'SPORTS',
|
||||
'An Olympic-sized swimming pool has a length of 50 meters. It is the standard length for competitive swimming events in the Olympics.',
|
||||
50
|
||||
),
|
||||
(
|
||||
'How many symphonies did Wolfgang Amadeus Mozart compose?',
|
||||
'ARTS',
|
||||
'Wolfgang Amadeus Mozart composed a total of 41 symphonies. His symphonies are considered masterpieces of classical music.',
|
||||
41
|
||||
),
|
||||
(
|
||||
'What is the duration of Beethoven''s Symphony No. 9 in minutes ?',
|
||||
'ARTS',
|
||||
'Beethoven''s Symphony No. 9, also known as the "Choral Symphony," has a duration of approximately 70 minutes. It is one of Beethoven''s most famous and enduring works.',
|
||||
70
|
||||
),
|
||||
(
|
||||
'How many acts are there in William Shakespeare''s play "Hamlet"?',
|
||||
'ARTS',
|
||||
'William Shakespeare''s play "Hamlet" is divided into five acts. It is a tragedy that explores themes of revenge, madness, and moral corruption.',
|
||||
5
|
||||
),
|
||||
(
|
||||
'In what year was Vincent van Gogh''s painting "Starry Night" created?',
|
||||
'ARTS',
|
||||
'Vincent van Gogh''s painting "Starry Night" was created in the year 1889. It is one of his most famous and recognizable works.',
|
||||
1889
|
||||
),
|
||||
(
|
||||
'How many sonnets did William Shakespeare write?',
|
||||
'ARTS',
|
||||
'William Shakespeare wrote a total of 154 sonnets. His sonnets are considered some of the greatest poetry in the English language.',
|
||||
154
|
||||
),
|
||||
(
|
||||
'How many ranks are there in the hierarchy of the Paris Opera Ballet?',
|
||||
'ARTS',
|
||||
'There are five ranks of dancers in the Paris Opera Ballet; from highest to lowest they are: Danseur Étoile, premier danseur, sujet, coryphée, and quadrille.',
|
||||
5
|
||||
),
|
||||
(
|
||||
'What is the total number of keys on a standard piano?',
|
||||
'ARTS',
|
||||
'A standard piano has a total of 88 keys. These keys include both white keys and black keys, spanning several octaves.',
|
||||
88
|
||||
),
|
||||
(
|
||||
'How many acts are there in Giuseppe Verdi''s opera "La Traviata"?',
|
||||
'ARTS',
|
||||
'Giuseppe Verdi''s opera "La Traviata" is divided into three acts. It is a tragic love story set in 19th-century Paris.',
|
||||
3
|
||||
),
|
||||
(
|
||||
'What is the running time of the film "Gone with the Wind" in minutes ?',
|
||||
'ARTS',
|
||||
'The film "Gone with the Wind," directed by Victor Fleming, has a running time of approximately 3 hours and 58 minutes. It is an epic historical romance set during the American Civil War.',
|
||||
238
|
||||
),
|
||||
(
|
||||
'How many atoms of carbon are there in a molecule of glucose?',
|
||||
'SCIE',
|
||||
'A molecule of glucose (C6H12O6) contains 6 atoms of carbon. Glucose is a simple sugar and an important source of energy in biological systems.',
|
||||
6
|
||||
),
|
||||
(
|
||||
'What is the atomic number of gold?',
|
||||
'SCIE',
|
||||
'The atomic number of gold is 79. Gold is a dense, soft, and highly valuable metal known for its lustrous yellow appearance.',
|
||||
79
|
||||
),
|
||||
(
|
||||
'How many chromosomes are found in a human somatic cell?',
|
||||
'SCIE',
|
||||
'A human somatic cell typically contains 46 chromosomes. These chromosomes carry genetic information and are located in the nucleus of the cell.',
|
||||
46
|
||||
),
|
||||
(
|
||||
'How many chambers are there in the heart of a frog?',
|
||||
'SCIE',
|
||||
'Unlike humans and other mammals, frogs have a three-chambered heart consisting of two atria and one ventricle. This unique cardiac structure allows for efficient circulation in amphibians.',
|
||||
3
|
||||
),
|
||||
(
|
||||
'What is the boiling point of liquid nitrogen in Kelvins?',
|
||||
'SCIE',
|
||||
'Liquid nitrogen has a boiling point of approximately -196 degrees Celsius, or 77 Kelvins. It is commonly used in various scientific and industrial applications due to its extremely low temperature.',
|
||||
77
|
||||
),
|
||||
(
|
||||
'How many bones are there in the human hand?',
|
||||
'SCIE',
|
||||
'The human hand is composed of 27 bones. These bones include the eight carpal bones in the wrist, five metacarpal bones in the palm, and 14 phalanges in the fingers.',
|
||||
27
|
||||
),
|
||||
(
|
||||
'What is the pH value of pure water at room temperature?',
|
||||
'SCIE',
|
||||
'Pure water at room temperature has a pH value of approximately 7, making it neutral on the pH scale. This means that it is neither acidic nor alkaline.',
|
||||
7
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
GET http://localhost:8080
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Welcome to Corporate Conundrum"
|
||||
body contains "Start a New Game"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/New%20Game.sql
|
||||
HTTP 302
|
||||
[Captures]
|
||||
game_id: header "Location" regex "id=(-?\\d+)"
|
||||
[Asserts]
|
||||
header "Location" matches "^game\\.sql\\?id=-?\\d+$"
|
||||
|
||||
GET http://localhost:8080/game.sql?id={{game_id}}&Player=Alice
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Players"
|
||||
body contains "Alice"
|
||||
body contains "Add a new player"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/next-question.sql?game_id={{game_id}}&player=Alice
|
||||
HTTP 302
|
||||
[Captures]
|
||||
question_id: header "Location" regex "question%5Fid=(\\d+)"
|
||||
[Asserts]
|
||||
header "Location" contains "question.sql"
|
||||
header "Location" contains "player=Alice"
|
||||
|
||||
GET http://localhost:8080/question.sql?game_id={{game_id}}&question_id={{question_id}}&player=Alice
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Submit your answer"
|
||||
body contains "Your answer"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/wait.sql?game_id={{game_id}}&question_id={{question_id}}&player=Alice&answer=42
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "Waiting for other players to answer"
|
||||
body not contains "An error occurred"
|
||||
|
||||
GET http://localhost:8080/next-question.sql?game_id={{game_id}}&player=Alice
|
||||
HTTP 302
|
||||
[Asserts]
|
||||
header "Location" contains "game-over.sql"
|
||||
|
||||
GET http://localhost:8080/game-over.sql?game_id={{game_id}}
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
body contains "The game is over"
|
||||
body contains "Scores"
|
||||
body contains "Alice"
|
||||
body not contains "An error occurred"
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Redirect to the next question when all players have answered
|
||||
set page_params = json_object('game_id', $game_id, 'player', $player);
|
||||
select CASE
|
||||
(SELECT count(*) FROM answers WHERE question_id = $question_id AND game_id = CAST($game_id AS INTEGER))
|
||||
WHEN (SELECT count(*) FROM players WHERE game_id = CAST($game_id AS INTEGER))
|
||||
THEN '0; ' || sqlpage.link('next-question.sql', $page_params)
|
||||
ELSE 3
|
||||
END as refresh,
|
||||
sqlpage_shell.*
|
||||
FROM sqlpage_shell;
|
||||
|
||||
-- Insert the answer into the answers table
|
||||
INSERT INTO answers(game_id, player_name, question_id, answer_value)
|
||||
SELECT CAST($game_id AS INTEGER) as game_id,
|
||||
$player as player_name,
|
||||
CAST($question_id AS INTEGER) as question_id,
|
||||
CAST($answer AS INTEGER) as answer_value
|
||||
WHERE $answer IS NOT NULL;
|
||||
-- Redirect to the next question
|
||||
SELECT 'text' as component,
|
||||
'Waiting for other players to answer... The following players still have not answered: ' as contents;
|
||||
select group_concat(name, ', ') as contents,
|
||||
TRUE as bold
|
||||
from players
|
||||
where game_id = CAST($game_id AS INTEGER)
|
||||
and not EXISTS (
|
||||
SELECT 1
|
||||
FROM answers
|
||||
WHERE answers.game_id = CAST($game_id AS INTEGER)
|
||||
AND answers.player_name = players.name
|
||||
AND answers.question_id = CAST($question_id AS INTEGER)
|
||||
);
|
||||
@@ -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;
|
||||
|
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"
|
||||