Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@

- Each item of the tracking component accepts a `link`. ([#900](https://github.com/sqlpage/SQLPage/issues/900))

- Each button of the button component accepts a `confirm` question, such as `'Delete this movie?' AS confirm`. ([#831](https://github.com/sqlpage/SQLPage/issues/831))

- Custom components can call inline partials inside `each_row` and after it, when the partials are defined before `each_row`. ([#846](https://github.com/sqlpage/SQLPage/issues/846))

## v0.47.0 (2026-10-15)
Expand Down
8 changes: 7 additions & 1 deletion examples/official-site/sqlpage/migrations/18_button.sql
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ INSERT INTO parameter(component, name, description, type, top_level, optional) S
('rel', '"nofollow" when the contents of the target link are not endorsed, "noopener" when the target is not trusted, and "noreferrer" to hide where the user came from when they open the link.', 'TEXT', FALSE, TRUE),
('target', '"_blank" to open the link in a new tab, "_self" to open it in the same tab, "_parent" to open it in the parent frame, or "_top" to open it in the full body of the window.', 'TEXT', FALSE, TRUE),
('download', 'If defined, the link will download the target instead of navigating to it. Set the value to the desired name of the downloaded file.', 'TEXT', FALSE, TRUE),
('id', 'HTML Identifier to add to the button element.', 'TEXT', FALSE, TRUE)
('id', 'HTML Identifier to add to the button element.', 'TEXT', FALSE, TRUE),
('confirm', 'A question to ask in a dialog before the button acts, such as "Delete this movie?".', 'TEXT', FALSE, TRUE)
) x;

-- Inserting example information for the button component
Expand All @@ -35,6 +36,11 @@ INSERT INTO example(component, description, properties) VALUES
json('[{"component":"button"}, {"link":"/documentation.sql", "title":"Enabled"}, {"link":"#", "title":"Disabled", "disabled":true}]'))
;

INSERT INTO example(component, description, properties) VALUES
('button', 'A button that asks for confirmation before it acts.',
json('[{"component":"button"}, {"link":"#", "title":"Delete", "color":"red", "icon":"trash", "confirm":"Delete this movie?"}]'))
;


INSERT INTO example(component, description, properties) VALUES
('button', 'A button with a custom shape, size, and outline color',
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/sqlpage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,3 +578,51 @@ function open_modal_for_hash() {

window.addEventListener("hashchange", open_modal_for_hash);
window.addEventListener("DOMContentLoaded", open_modal_for_hash);

function ask_confirmation(action: HTMLElement, question: string) {
const dialog = document.createElement("div");
dialog.className = "modal";
dialog.tabIndex = -1;
dialog.setAttribute("aria-label", question);
dialog.innerHTML = `<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
<div class="modal-body text-center py-4"><p class="mb-0"></p></div>
<div class="modal-footer"></div>
</div>
</div>`;
const text = dialog.querySelector("p");
const footer = dialog.querySelector(".modal-footer");
if (!text || !footer) throw new Error("Invalid confirmation dialog");
text.textContent = question;
const confirmed_action = action.cloneNode(true);
if (!(confirmed_action instanceof HTMLElement))
throw new Error("Invalid confirmed action");
for (const attribute of [
"id",
"data-confirm",
"data-bs-toggle",
"data-bs-target",
])
confirmed_action.removeAttribute(attribute);
confirmed_action.classList.remove("me-auto");
footer.appendChild(confirmed_action);
document.body.appendChild(dialog);
const modal = new (page_bootstrap().Modal)(dialog);
// Hiding removes the dialog at once, and a removed button no longer submits
// its form, so the dialog closes only after the click has done its work.
confirmed_action.addEventListener("click", () =>
setTimeout(() => modal.hide()),
);
dialog.addEventListener("hidden.bs.modal", () => dialog.remove());
modal.show();
}

document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const action = event.target.closest<HTMLElement>("[data-confirm]");
const question = action?.dataset.confirm;
if (!action || !question) return;
event.preventDefault();
ask_confirmation(action, question);
});
1 change: 1 addition & 0 deletions sqlpage/templates/button.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
{{~#if rel}} rel="{{rel}}"{{/if}}
{{~#if tooltip}} data-bs-toggle="tooltip" data-bs-placement="top" title="{{tooltip}}"{{/if}}
{{~#if modal}} data-bs-toggle="modal" data-bs-target="#{{modal}}"{{/if}}
{{~#if confirm}} data-confirm="{{confirm}}"{{/if}}
role="button">
{{~#if image~}}
<span {{~#if title}} class="me-1"{{/if}}>
Expand Down
1 change: 1 addition & 0 deletions tests/end-to-end/fixtures/button/confirm/archived.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT 'text' AS component, 'Archived ' || :movie AS contents;
1 change: 1 addition & 0 deletions tests/end-to-end/fixtures/button/confirm/deleted.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
SELECT 'text' AS component, 'Deleted' AS contents;
6 changes: 6 additions & 0 deletions tests/end-to-end/fixtures/button/confirm/index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
SELECT 'form' AS component, 'movie' AS id, '' AS validate;
SELECT 'hidden' AS type, 'movie' AS name, 'Alien' AS value;

SELECT 'button' AS component;
SELECT 'Delete' AS title, 'deleted.sql' AS link, 'Delete this movie?' AS confirm;
SELECT 'Archive' AS title, 'movie' AS form, 'archived.sql' AS link, 'Archive this movie?' AS confirm;
27 changes: 27 additions & 0 deletions tests/end-to-end/fixtures/button/confirm/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expect, test } from "../../../fixture.ts";

test("cancelling a confirmation stays on the page", async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).click();
const dialog = page.getByRole("dialog", { name: "Delete this movie?" });
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toBeHidden();
await expect(page).toHaveURL(/\/button\/confirm\/$/);
});

test("confirming follows the button's link", async ({ page }) => {
await page.getByRole("button", { name: "Delete" }).click();
await page
.getByRole("dialog", { name: "Delete this movie?" })
.getByRole("button", { name: "Delete" })
.click();
await expect(page.getByText("Deleted")).toBeVisible();
});

test("confirming submits the button's form", async ({ page }) => {
await page.getByRole("button", { name: "Archive" }).click();
await page
.getByRole("dialog", { name: "Archive this movie?" })
.getByRole("button", { name: "Archive" })
.click();
await expect(page.getByText("Archived Alien")).toBeVisible();
});
Loading